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 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, reference_agent_versions_by_product, ) from app.services.agent_git_release import get_git_release_versions from app.services.agent_update import ( AgentUpdateNotAllowedError, 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 ( PRODUCT_RDP, PRODUCT_SSH, get_effective_agent_update_config, ) from app.services.agent_host_config import get_host_agent_settings, update_host_agent_config from app.services.host_sessions import ( HostSessionRow, list_linux_sessions, list_windows_sessions, terminate_linux_session, terminate_windows_session, ) 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, ) from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter router = APIRouter(prefix="/hosts", tags=["hosts"]) def _count_visible_events(db: Session, host_id: int, hidden: frozenset[str]) -> int: stmt = select(func.count()).select_from(Event).where(Event.host_id == host_id) type_filter = visibility_type_filter(hidden) if type_filter is not None: stmt = stmt.where(type_filter) return int(db.scalar(stmt) or 0) @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) latest_map = latest_agent_versions_by_product(db) update_cfg = get_effective_agent_update_config(db) git_release = get_git_release_versions(update_cfg, db=db) reference_map = reference_agent_versions_by_product( update_cfg, latest_map, git_versions=git_release.versions, ) hidden = get_hidden_event_types(db) items: list[HostSummary] = [] for host in rows: event_count = _count_visible_events(db, host.id, hidden) 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 ), version_status=host_version_status( host, cfg=update_cfg, latest_map=latest_map, git_versions=git_release.versions, ), pending_agent_update=bool(host.pending_agent_update), ) ) return HostListResponse( items=items, total=total, page=page, page_size=page_size, latest_agent_versions=latest_map, reference_agent_versions=reference_map, git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None, git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None, ) 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) hidden = get_hidden_event_types(db) event_count = _count_visible_events(db, host.id, hidden) last_hb = hb_map.get(host.id) latest_map = latest_agent_versions_by_product(db) update_cfg = get_effective_agent_update_config(db) git_release = get_git_release_versions(update_cfg, db=db) 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), version_status=host_version_status( host, cfg=update_cfg, latest_map=latest_map, git_versions=git_release.versions, ), pending_agent_update=bool(host.pending_agent_update), 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, ssh_admin_ok=host.ssh_admin_ok, ssh_admin_checked_at=host.ssh_admin_checked_at, pending_update_requested_at=host.pending_update_requested_at, pending_update_target_version=host.pending_update_target_version, agent_config_revision=int(host.agent_config_revision or 0), agent_config=get_host_agent_settings(host) or None, agent_update_state=host.agent_update_state, agent_update_last_at=host.agent_update_last_at, agent_update_last_error=host.agent_update_last_error, ) def _set_ssh_admin_status(host: Host, ok: bool) -> None: host.ssh_admin_ok = ok host.ssh_admin_checked_at = datetime.now(timezone.utc) @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 product_version: str | None = None ssh_admin_ok: bool | None = None class HostAgentUpdateRequestResponse(BaseModel): status: str pending_agent_update: bool target_version: str | None = None agent_update_state: str | None = None class HostAgentConfigUpdate(BaseModel): settings: dict[str, object] = {} class HostAgentConfigResponse(BaseModel): config_revision: int 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 class HostSessionItem(BaseModel): session_id: str user: str tty: str | None = None state: str | None = None source_ip: str | None = None session_name: str | None = None class HostSessionsResponse(BaseModel): ok: bool message: str target: str | None = None sessions: list[HostSessionItem] class HostSessionTerminateBody(BaseModel): session_id: str class HostSessionTerminateResponse(BaseModel): ok: bool message: str target: str | None = None stdout: str | None = None stderr: str | None = None def _session_items(rows: list[HostSessionRow]) -> list[HostSessionItem]: return [ HostSessionItem( session_id=r.session_id, user=r.user, tty=r.tty, state=r.state, source_ip=r.source_ip, session_name=r.session_name, ) for r in rows ] def _ssh_action_response( result: SshCommandResult, *, attempts: list[str] | None = None, ssh_admin_ok: bool | 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, product_version=result.agent_version, ssh_admin_ok=ssh_admin_ok, ) 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") response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection) _set_ssh_admin_status(host, response.ok) db.commit() return response.model_copy(update={"ssh_admin_ok": host.ssh_admin_ok}) @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), ) -> HostRemoteActionStartResponse: 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") 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 и продолжится в фоне", ) @router.post("/{host_id}/actions/request-agent-update", response_model=HostAgentUpdateRequestResponse) def post_request_agent_update( host_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostAgentUpdateRequestResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") try: request_agent_update(db, host) except AgentUpdateNotAllowedError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc db.commit() return HostAgentUpdateRequestResponse( status="requested", pending_agent_update=host.pending_agent_update, target_version=host.pending_update_target_version, agent_update_state=host.agent_update_state, ) @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), ) -> HostRemoteActionStartResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") 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.post("/{host_id}/actions/sessions/list", response_model=HostSessionsResponse) def list_host_sessions( host_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostSessionsResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") from app.services.ssh_connect import is_linux_host from app.services.winrm_connect import is_windows_host if is_linux_host(host): cfg = get_effective_linux_admin_config(db) if not cfg.configured: raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") try: sessions, result = list_linux_sessions(host, cfg) 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 return HostSessionsResponse( ok=result.ok, message=result.message, target=result.target or None, sessions=_session_items(sessions), ) if is_windows_host(host): cfg = get_effective_win_admin_config(db) if not cfg.configured: raise HTTPException(status_code=400, detail="Windows domain admin is not configured") try: sessions, result = list_windows_sessions(host, cfg) 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 if result is None: raise HTTPException(status_code=502, detail="WinRM qwinsta failed") return HostSessionsResponse( ok=result.ok, message=result.message, target=result.target or None, sessions=_session_items(sessions), ) raise HTTPException(status_code=400, detail="Host OS does not support live sessions") @router.post("/{host_id}/actions/sessions/terminate", response_model=HostSessionTerminateResponse) def terminate_host_session( host_id: int, body: HostSessionTerminateBody, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostSessionTerminateResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") from app.services.ssh_connect import is_linux_host from app.services.winrm_connect import is_windows_host sid = body.session_id.strip() if not sid: raise HTTPException(status_code=422, detail="session_id is required") if is_linux_host(host): cfg = get_effective_linux_admin_config(db) if not cfg.configured: raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") try: result = terminate_linux_session(host, cfg, sid) 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 return HostSessionTerminateResponse( ok=result.ok, message=result.message, target=result.target or None, stdout=result.stdout or None, stderr=result.stderr or None, ) if is_windows_host(host): cfg = get_effective_win_admin_config(db) if not cfg.configured: raise HTTPException(status_code=400, detail="Windows domain admin is not configured") try: result = terminate_windows_session(host, cfg, sid) except HostNotWindowsError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if result is None: raise HTTPException(status_code=502, detail="WinRM logoff failed") return HostSessionTerminateResponse( ok=result.ok, message=result.message, target=result.target or None, stdout=result.stdout or None, stderr=result.stderr or None, ) raise HTTPException(status_code=400, detail="Host OS does not support session terminate") @router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse) def patch_host_agent_config( host_id: int, body: HostAgentConfigUpdate, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostAgentConfigResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") try: update_host_agent_config(db, host, body.settings) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc db.commit() settings = get_host_agent_settings(host) return HostAgentConfigResponse( config_revision=int(host.agent_config_revision or 0), settings=settings, ) @router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse) def get_host_agent_config( host_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostAgentConfigResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") settings = get_host_agent_settings(host) return HostAgentConfigResponse( config_revision=int(host.agent_config_revision or 0), settings=settings, ) @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, )