Files
security-alert-center/backend/app/api/v1/hosts.py
T

354 lines
12 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth.jwt_auth import get_current_user, require_admin
from app.config import get_settings
from app.database import get_db
from app.models import Event, Host
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
from app.services.agent_version import latest_agent_versions_by_product
from app.services.host_delete import delete_host_and_related
from app.services.linux_admin_settings import get_effective_linux_admin_config
from app.services.win_admin_settings import get_effective_win_admin_config
from app.services.winrm_connect import (
HostNotWindowsError,
HostTargetMissingError,
WinAdminNotConfiguredError,
WinRmTestResult,
iter_winrm_targets,
test_winrm_connection,
)
from app.services.ssh_connect import (
HostNotLinuxError as SshHostNotLinuxError,
HostTargetMissingError as SshHostTargetMissingError,
LinuxAdminNotConfiguredError,
SshCommandResult,
iter_ssh_targets,
run_ssh_monitor_update,
test_ssh_connection,
)
from app.services.host_health import (
HEARTBEAT_TYPE,
agent_status as compute_agent_status,
apply_agent_status_host_filter,
max_daily_report_time_by_host,
max_event_time_by_host,
)
router = APIRouter(prefix="/hosts", tags=["hosts"])
@router.get("", response_model=HostListResponse)
def list_hosts(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
product: str | None = None,
hostname: str | None = None,
agent_status: str | None = Query(None, pattern="^(online|stale|unknown)$"),
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> HostListResponse:
stmt = select(Host)
count_stmt = select(func.count()).select_from(Host)
if product:
stmt = stmt.where(Host.product == product)
count_stmt = count_stmt.where(Host.product == product)
if hostname:
like = f"%{hostname}%"
host_match = Host.hostname.ilike(like) | Host.display_name.ilike(like)
stmt = stmt.where(host_match)
count_stmt = count_stmt.where(host_match)
settings = get_settings()
if agent_status:
stmt, count_stmt = apply_agent_status_host_filter(
stmt,
count_stmt,
agent_status,
stale_minutes=settings.sac_heartbeat_stale_minutes,
)
total = db.scalar(count_stmt) or 0
rows = db.scalars(
stmt.order_by(Host.last_seen_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
).all()
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
report_map = max_daily_report_time_by_host(db)
items: list[HostSummary] = []
for host in rows:
event_count = db.scalar(
select(func.count()).select_from(Event).where(Event.host_id == host.id)
)
last_hb = hb_map.get(host.id)
items.append(
HostSummary(
id=host.id,
hostname=host.hostname,
display_name=host.display_name,
os_family=host.os_family,
os_version=host.os_version,
product=host.product,
product_version=host.product_version,
ipv4=host.ipv4,
last_seen_at=host.last_seen_at,
event_count=int(event_count or 0),
last_heartbeat_at=last_hb,
last_daily_report_at=report_map.get(host.id),
last_inventory_at=host.inventory_updated_at,
agent_status=compute_agent_status(
last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes
),
)
)
return HostListResponse(
items=items,
total=total,
page=page,
page_size=page_size,
latest_agent_versions=latest_agent_versions_by_product(db),
)
def _host_detail_from_model(
host: Host,
*,
db: Session,
settings,
) -> HostDetail:
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
report_map = max_daily_report_time_by_host(db)
event_count = db.scalar(
select(func.count()).select_from(Event).where(Event.host_id == host.id)
)
last_hb = hb_map.get(host.id)
return HostDetail(
id=host.id,
hostname=host.hostname,
display_name=host.display_name,
os_family=host.os_family,
os_version=host.os_version,
product=host.product,
product_version=host.product_version,
ipv4=host.ipv4,
ipv6=host.ipv6,
last_seen_at=host.last_seen_at,
event_count=int(event_count or 0),
last_heartbeat_at=last_hb,
last_daily_report_at=report_map.get(host.id),
last_inventory_at=host.inventory_updated_at,
agent_status=compute_agent_status(last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes),
agent_instance_id=host.agent_instance_id,
tags=host.tags if isinstance(host.tags, list) else [],
use_sac_mode=host.use_sac_mode,
inventory=host.inventory if isinstance(host.inventory, dict) else None,
inventory_updated_at=host.inventory_updated_at,
created_at=host.created_at,
)
@router.get("/{host_id}", response_model=HostDetail)
def get_host(
host_id: int,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> HostDetail:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
settings = get_settings()
return _host_detail_from_model(host, db=db, settings=settings)
class HostDeleteResponse(BaseModel):
status: str
host_id: int
hostname: str
deleted_events: int
deleted_problems: int
class HostWinRmTestResponse(BaseModel):
ok: bool
message: str
target: str
hostname: str | None = None
class HostSshActionResponse(BaseModel):
ok: bool
message: str
target: str
stdout: str | None = None
stderr: str | None = None
exit_code: int | None = None
def _ssh_action_response(result: SshCommandResult, *, attempts: list[str] | None = None) -> HostSshActionResponse:
message = result.message
if attempts:
message = f"{message} (пробовали: {', '.join(attempts)})"
return HostSshActionResponse(
ok=result.ok,
message=message,
target=result.target,
stdout=result.stdout or None,
stderr=result.stderr or None,
exit_code=result.exit_code,
)
def _run_ssh_action_on_host(
host: Host,
cfg,
*,
action,
) -> HostSshActionResponse:
try:
targets = iter_ssh_targets(host)
except SshHostNotLinuxError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SshHostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
last_result: SshCommandResult | None = None
attempts: list[str] = []
for target in targets:
try:
result = action(target=target, user=cfg.user, password=cfg.password)
except LinuxAdminNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
last_result = result
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
if result.ok:
return _ssh_action_response(result, attempts=attempts if len(attempts) > 1 else None)
assert last_result is not None
return _ssh_action_response(last_result, attempts=attempts if len(attempts) > 1 else None)
@router.post("/{host_id}/actions/winrm-test", response_model=HostWinRmTestResponse)
def test_host_winrm(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostWinRmTestResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
try:
targets = iter_winrm_targets(host)
except HostNotWindowsError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except HostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
last_result = None
attempts: list[str] = []
for target in targets:
try:
result = test_winrm_connection(
target=target,
user=cfg.user,
password=cfg.password,
)
except WinAdminNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
last_result = result
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
if result.ok:
if len(targets) > 1:
result = WinRmTestResult(
ok=True,
message=f"{result.message} (пробовали: {', '.join(attempts)})",
target=result.target,
hostname=result.hostname,
)
return HostWinRmTestResponse(
ok=result.ok,
message=result.message,
target=result.target,
hostname=result.hostname,
)
assert last_result is not None
message = last_result.message
if len(attempts) > 1:
message = f"{message} (пробовали: {', '.join(attempts)})"
return HostWinRmTestResponse(
ok=False,
message=message,
target=last_result.target,
hostname=last_result.hostname,
)
@router.post("/{host_id}/actions/ssh-test", response_model=HostSshActionResponse)
def test_host_ssh(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostSshActionResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
return _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
@router.post("/{host_id}/actions/agent-update", response_model=HostSshActionResponse)
def update_host_agent_via_ssh(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostSshActionResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
return _run_ssh_action_on_host(host, cfg, action=run_ssh_monitor_update)
@router.delete("/{host_id}", response_model=HostDeleteResponse)
def delete_host(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostDeleteResponse:
result = delete_host_and_related(db, host_id)
if result is None:
raise HTTPException(status_code=404, detail="Host not found")
db.commit()
return HostDeleteResponse(
status="deleted",
host_id=result.host_id,
hostname=result.hostname,
deleted_events=result.deleted_events,
deleted_problems=result.deleted_problems,
)