From cd2f27792da465de17a32b516cdd7d572e3ab9da Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 15:52:10 +1000 Subject: [PATCH] feat: agent updates from SAC, poll config, SSH/WinRM fallback (0.12.0) Add agent update settings, pending self-update via poll, desired config per host, and automatic or manual SSH/WinRM fallback when agents do not report agent.update events. --- .../versions/020_agent_control_plane.py | 98 +++++++ backend/app/api/v1/agent.py | 14 +- backend/app/api/v1/hosts.py | 120 +++++++++ backend/app/api/v1/settings.py | 72 +++++ backend/app/config.py | 10 + backend/app/jobs/host_silence_background.py | 9 + backend/app/models/host.py | 8 + backend/app/models/ui_settings.py | 8 + backend/app/schemas/list_models.py | 9 + backend/app/services/agent_host_config.py | 74 ++++++ backend/app/services/agent_poll.py | 31 +++ backend/app/services/agent_update.py | 249 ++++++++++++++++++ backend/app/services/agent_update_settings.py | 162 ++++++++++++ backend/app/services/ingest.py | 3 + backend/app/services/winrm_connect.py | 30 +++ backend/app/version.py | 2 +- backend/tests/test_agent_update.py | 208 +++++++++++++++ backend/tests/test_health.py | 4 +- frontend/src/api.ts | 69 +++++ frontend/src/version.ts | 2 +- frontend/src/views/HostDetailView.vue | 223 +++++++++++++++- frontend/src/views/HostsView.vue | 2 + frontend/src/views/SettingsView.vue | 97 +++++++ schemas/event-schema-v1.json | 5 +- 24 files changed, 1494 insertions(+), 15 deletions(-) create mode 100644 backend/alembic/versions/020_agent_control_plane.py create mode 100644 backend/app/services/agent_host_config.py create mode 100644 backend/app/services/agent_poll.py create mode 100644 backend/app/services/agent_update.py create mode 100644 backend/app/services/agent_update_settings.py create mode 100644 backend/tests/test_agent_update.py diff --git a/backend/alembic/versions/020_agent_control_plane.py b/backend/alembic/versions/020_agent_control_plane.py new file mode 100644 index 0000000..42d5f42 --- /dev/null +++ b/backend/alembic/versions/020_agent_control_plane.py @@ -0,0 +1,98 @@ +"""agent control plane: host update/config + ui agent-update settings + +Revision ID: 020 +Revises: 019 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +revision: str = "020" +down_revision: Union[str, None] = "019" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "hosts", + sa.Column("pending_agent_update", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.add_column( + "hosts", + sa.Column("pending_update_requested_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "hosts", + sa.Column("pending_update_target_version", sa.String(length=64), nullable=True), + ) + op.add_column( + "hosts", + sa.Column("agent_config", JSONB, nullable=True), + ) + op.add_column( + "hosts", + sa.Column("agent_config_revision", sa.Integer(), nullable=False, server_default="0"), + ) + op.add_column( + "hosts", + sa.Column("agent_update_state", sa.String(length=32), nullable=True), + ) + op.add_column( + "hosts", + sa.Column("agent_update_last_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column("hosts", sa.Column("agent_update_last_error", sa.Text(), nullable=True)) + + op.add_column( + "ui_settings", + sa.Column("agent_update_mode", sa.String(length=16), nullable=False, server_default="gpo"), + ) + op.add_column( + "ui_settings", + sa.Column("agent_update_fallback_enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + op.add_column( + "ui_settings", + sa.Column("agent_update_fallback_minutes", sa.Integer(), nullable=False, server_default="15"), + ) + op.add_column( + "ui_settings", + sa.Column("agent_recommended_rdp_version", sa.String(length=64), nullable=True), + ) + op.add_column( + "ui_settings", + sa.Column("agent_recommended_ssh_version", sa.String(length=64), nullable=True), + ) + op.add_column( + "ui_settings", + sa.Column("agent_min_rdp_version", sa.String(length=64), nullable=True), + ) + op.add_column( + "ui_settings", + sa.Column("agent_min_ssh_version", sa.String(length=64), nullable=True), + ) + op.add_column("ui_settings", sa.Column("win_agent_update_script", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("ui_settings", "win_agent_update_script") + op.drop_column("ui_settings", "agent_min_ssh_version") + op.drop_column("ui_settings", "agent_min_rdp_version") + op.drop_column("ui_settings", "agent_recommended_ssh_version") + op.drop_column("ui_settings", "agent_recommended_rdp_version") + op.drop_column("ui_settings", "agent_update_fallback_minutes") + op.drop_column("ui_settings", "agent_update_fallback_enabled") + op.drop_column("ui_settings", "agent_update_mode") + + op.drop_column("hosts", "agent_update_last_error") + op.drop_column("hosts", "agent_update_last_at") + op.drop_column("hosts", "agent_update_state") + op.drop_column("hosts", "agent_config_revision") + op.drop_column("hosts", "agent_config") + op.drop_column("hosts", "pending_update_target_version") + op.drop_column("hosts", "pending_update_requested_at") + op.drop_column("hosts", "pending_agent_update") diff --git a/backend/app/api/v1/agent.py b/backend/app/api/v1/agent.py index 7020843..ef4b26d 100644 --- a/backend/app/api/v1/agent.py +++ b/backend/app/api/v1/agent.py @@ -7,11 +7,10 @@ from sqlalchemy.orm import Session from app.auth.api_key import get_api_key_auth from app.database import get_db from app.services.agent_commands import ( - command_to_dict, complete_command, - get_command_for_host_poll, resolve_host_by_agent_instance_id, ) +from app.services.agent_poll import build_agent_poll_payload router = APIRouter(prefix="/agent", tags=["agent"]) @@ -23,7 +22,10 @@ class AgentCommandResultBody(BaseModel): class AgentCommandsPollResponse(BaseModel): - commands: list[dict[str, Any]] + config_revision: int = 0 + config: dict[str, Any] = Field(default_factory=dict) + update: dict[str, Any] = Field(default_factory=dict) + commands: list[dict[str, Any]] = Field(default_factory=list) @router.get("/commands", response_model=AgentCommandsPollResponse) @@ -35,10 +37,8 @@ def poll_agent_commands( host = resolve_host_by_agent_instance_id(db, agent_instance_id) if host is None: raise HTTPException(status_code=404, detail="Unknown agent_instance_id") - pending = get_command_for_host_poll(db, host) - return AgentCommandsPollResponse( - commands=[command_to_dict(c, include_run_as=True) for c in pending] - ) + payload = build_agent_poll_payload(db, host) + return AgentCommandsPollResponse(**payload) @router.post("/commands/{command_uuid}/result") diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index b348d18..114dd75 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -11,6 +11,14 @@ 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.agent_update import ( + AgentUpdateNotAllowedError, + execute_agent_update_fallback, + host_version_status, + request_agent_update, +) +from app.services.agent_update_settings import get_effective_agent_update_config +from app.services.agent_host_config import get_host_agent_settings, update_host_agent_config 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 @@ -82,6 +90,8 @@ def list_hosts( 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) items: list[HostSummary] = [] for host in rows: @@ -107,6 +117,8 @@ def list_hosts( 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), + pending_agent_update=bool(host.pending_agent_update), ) ) @@ -131,6 +143,8 @@ def _host_detail_from_model( select(func.count()).select_from(Event).where(Event.host_id == host.id) ) last_hb = hb_map.get(host.id) + latest_map = latest_agent_versions_by_product(db) + update_cfg = get_effective_agent_update_config(db) return HostDetail( id=host.id, hostname=host.hostname, @@ -147,6 +161,8 @@ def _host_detail_from_model( 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), + 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, @@ -155,6 +171,13 @@ def _host_detail_from_model( 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, ) @@ -202,6 +225,22 @@ class HostSshActionResponse(BaseModel): 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] + + def _ssh_action_response( result: SshCommandResult, *, @@ -364,6 +403,87 @@ def update_host_agent_via_ssh( ) +@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=HostSshActionResponse) +def post_agent_update_fallback( + 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") + + ok, message, version = execute_agent_update_fallback(db, host) + db.commit() + return HostSshActionResponse( + ok=ok, + message=message, + target=host.hostname, + product_version=version or (host.product_version if ok else None), + ssh_admin_ok=host.ssh_admin_ok, + ) + + +@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, diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 2cf8ae7..d80ae81 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -45,6 +45,10 @@ from app.services.win_admin_settings import ( get_effective_win_admin_config, upsert_win_admin_settings, ) +from app.services.agent_update_settings import ( + get_effective_agent_update_config, + upsert_agent_update_settings, +) from app.services.winrm_connect import ( HostNotWindowsError, HostTargetMissingError, @@ -540,3 +544,71 @@ def update_linux_admin_settings( password_hint=_mask_secret(cfg.password), source=cfg.source, ) + + +class AgentUpdateSettingsResponse(BaseModel): + mode: str + fallback_enabled: bool + fallback_after_minutes: int + recommended_rdp_version: str | None = None + recommended_ssh_version: str | None = None + min_rdp_version: str | None = None + min_ssh_version: str | None = None + win_agent_update_script: str | None = None + source: str = Field(description="env или db") + + +class AgentUpdateSettingsUpdate(BaseModel): + mode: str | None = None + fallback_enabled: bool | None = None + fallback_after_minutes: int | None = None + recommended_rdp_version: str | None = None + recommended_ssh_version: str | None = None + min_rdp_version: str | None = None + min_ssh_version: str | None = None + win_agent_update_script: str | None = None + + +def _agent_update_to_response(cfg) -> AgentUpdateSettingsResponse: + return AgentUpdateSettingsResponse( + mode=cfg.mode, + fallback_enabled=cfg.fallback_enabled, + fallback_after_minutes=cfg.fallback_after_minutes, + recommended_rdp_version=cfg.recommended_rdp_version or None, + recommended_ssh_version=cfg.recommended_ssh_version or None, + min_rdp_version=cfg.min_rdp_version or None, + min_ssh_version=cfg.min_ssh_version or None, + win_agent_update_script=cfg.win_agent_update_script or None, + source=cfg.source, + ) + + +@router.get("/agent-updates", response_model=AgentUpdateSettingsResponse) +def get_agent_update_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> AgentUpdateSettingsResponse: + return _agent_update_to_response(get_effective_agent_update_config(db)) + + +@router.put("/agent-updates", response_model=AgentUpdateSettingsResponse) +def update_agent_update_settings( + body: AgentUpdateSettingsUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> AgentUpdateSettingsResponse: + try: + cfg = upsert_agent_update_settings( + db, + mode=body.mode, + fallback_enabled=body.fallback_enabled, + fallback_after_minutes=body.fallback_after_minutes, + recommended_rdp_version=body.recommended_rdp_version, + recommended_ssh_version=body.recommended_ssh_version, + min_rdp_version=body.min_rdp_version, + min_ssh_version=body.min_ssh_version, + win_agent_update_script=body.win_agent_update_script, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return _agent_update_to_response(cfg) diff --git a/backend/app/config.py b/backend/app/config.py index 5d022dc..d855c97 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -115,6 +115,16 @@ class Settings(BaseSettings): sac_linux_admin_user: str = "" sac_linux_admin_password: str = "" + # Agent updates (mode gpo|sac, recommended versions, WinRM fallback script) + sac_agent_update_mode: str = "gpo" + sac_agent_update_fallback_enabled: bool = True + sac_agent_update_fallback_minutes: int = 15 + sac_agent_recommended_rdp_version: str = "" + sac_agent_recommended_ssh_version: str = "" + sac_agent_min_rdp_version: str = "" + sac_agent_min_ssh_version: str = "" + sac_win_agent_update_script: str = "" + # Retention (app.jobs.retention / systemd timer) sac_events_retention_days: int = 90 sac_problems_retention_days: int = 180 diff --git a/backend/app/jobs/host_silence_background.py b/backend/app/jobs/host_silence_background.py index 8a09983..2535aba 100644 --- a/backend/app/jobs/host_silence_background.py +++ b/backend/app/jobs/host_silence_background.py @@ -36,6 +36,9 @@ def _run_scan_once() -> None: try: results = run_host_silence_scan(db) notified = dispatch_host_silence_notifications(db, results) + from app.services.agent_update import process_agent_update_fallbacks + + fallback_results = process_agent_update_fallbacks(db) db.commit() if results: logger.info( @@ -44,6 +47,12 @@ def _run_scan_once() -> None: sum(1 for r in results if r.created), notified, ) + if fallback_results: + logger.info( + "agent_update fallback: processed=%s ok=%s", + len(fallback_results), + sum(1 for r in fallback_results if r.get("ok")), + ) except Exception: db.rollback() raise diff --git a/backend/app/models/host.py b/backend/app/models/host.py index 88ba303..6a8a607 100644 --- a/backend/app/models/host.py +++ b/backend/app/models/host.py @@ -26,6 +26,14 @@ class Host(Base): use_sac_mode: Mapped[str | None] = mapped_column(String(32)) ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True) ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + pending_agent_update: Mapped[bool] = mapped_column(default=False, server_default="false") + pending_update_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + pending_update_target_version: Mapped[str | None] = mapped_column(String(64)) + agent_config: Mapped[dict | None] = mapped_column(JSONB) + agent_config_revision: Mapped[int] = mapped_column(default=0, server_default="0") + agent_update_state: Mapped[str | None] = mapped_column(String(32)) + agent_update_last_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + agent_update_last_error: Mapped[str | None] = mapped_column(Text) last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/ui_settings.py b/backend/app/models/ui_settings.py index c8fb3cb..058014f 100644 --- a/backend/app/models/ui_settings.py +++ b/backend/app/models/ui_settings.py @@ -17,3 +17,11 @@ class UiSettings(Base): win_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True) linux_admin_user: Mapped[str | None] = mapped_column(String(128), nullable=True) linux_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True) + agent_update_mode: Mapped[str] = mapped_column(String(16), default="gpo", nullable=False) + agent_update_fallback_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + agent_update_fallback_minutes: Mapped[int] = mapped_column(default=15, nullable=False) + agent_recommended_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + agent_recommended_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + agent_min_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + agent_min_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + win_agent_update_script: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/schemas/list_models.py b/backend/app/schemas/list_models.py index fa3466f..6f89b9c 100644 --- a/backend/app/schemas/list_models.py +++ b/backend/app/schemas/list_models.py @@ -18,6 +18,8 @@ class HostSummary(BaseModel): last_daily_report_at: datetime | None = None last_inventory_at: datetime | None = None agent_status: str = "unknown" + version_status: str = "unknown" + pending_agent_update: bool = False model_config = {"from_attributes": True} @@ -32,6 +34,13 @@ class HostDetail(HostSummary): created_at: datetime | None = None ssh_admin_ok: bool | None = None ssh_admin_checked_at: datetime | None = None + pending_update_requested_at: datetime | None = None + pending_update_target_version: str | None = None + agent_config_revision: int = 0 + agent_config: dict | None = None + agent_update_state: str | None = None + agent_update_last_at: datetime | None = None + agent_update_last_error: str | None = None class HostListResponse(BaseModel): diff --git a/backend/app/services/agent_host_config.py b/backend/app/services/agent_host_config.py new file mode 100644 index 0000000..c0f0496 --- /dev/null +++ b/backend/app/services/agent_host_config.py @@ -0,0 +1,74 @@ +"""Desired agent config per host (poll delivery).""" + +from __future__ import annotations + +from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified + +from app.models import Host +from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH + +RDP_CONFIG_KEYS = frozenset( + { + "ServerDisplayName", + "EnableRcmShadowControlMonitoring", + "EnableWinRmInboundMonitoring", + "EnableAdminShareMonitoring", + "GetInventory", + "SacCommandPollIntervalSec", + } +) +SSH_CONFIG_KEYS = frozenset( + { + "SERVER_DISPLAY_NAME", + "HEARTBEAT_INTERVAL", + "DAILY_REPORT_ENABLED", + "UseSAC", + } +) + + +def _allowed_keys(host: Host) -> frozenset[str]: + product = (host.product or "").strip() + if product == PRODUCT_RDP or (host.os_family or "").strip().lower() == "windows": + return RDP_CONFIG_KEYS + if product == PRODUCT_SSH or (host.os_family or "").strip().lower() == "linux": + return SSH_CONFIG_KEYS + return frozenset() + + +def get_host_agent_settings(host: Host) -> dict: + raw = host.agent_config if isinstance(host.agent_config, dict) else {} + allowed = _allowed_keys(host) + if not allowed: + return {} + return {k: v for k, v in raw.items() if k in allowed} + + +def build_agent_config_payload(host: Host) -> dict: + settings = get_host_agent_settings(host) + if not settings: + return {} + return {"settings": settings} + + +def update_host_agent_config(db: Session, host: Host, settings: dict) -> Host: + allowed = _allowed_keys(host) + if not allowed: + raise ValueError("Host product does not support desired agent config") + + current = host.agent_config if isinstance(host.agent_config, dict) else {} + merged = dict(current) + for key, value in settings.items(): + if key not in allowed: + raise ValueError(f"Unsupported config key: {key}") + if value is None: + merged.pop(key, None) + else: + merged[key] = value + + host.agent_config = merged + host.agent_config_revision = int(host.agent_config_revision or 0) + 1 + flag_modified(host, "agent_config") + db.flush() + return host diff --git a/backend/app/services/agent_poll.py b/backend/app/services/agent_poll.py new file mode 100644 index 0000000..f412806 --- /dev/null +++ b/backend/app/services/agent_poll.py @@ -0,0 +1,31 @@ +"""Agent poll payload: commands, desired config, pending update.""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session + +from app.models import Host +from app.services.agent_commands import command_to_dict, get_command_for_host_poll +from app.services.agent_host_config import build_agent_config_payload +from app.services.agent_update_settings import get_effective_agent_update_config + + +def build_agent_poll_payload(db: Session, host: Host) -> dict[str, Any]: + cfg = get_effective_agent_update_config(db) + pending = get_command_for_host_poll(db, host) + config_payload = build_agent_config_payload(host) + + update_block: dict[str, Any] = { + "requested": bool(host.pending_agent_update), + "target_version": host.pending_update_target_version, + "source": "sac" if cfg.sac_managed else "gpo", + } + + return { + "config_revision": int(host.agent_config_revision or 0), + "config": config_payload, + "update": update_block, + "commands": [command_to_dict(c, include_run_as=True) for c in pending], + } diff --git a/backend/app/services/agent_update.py b/backend/app/services/agent_update.py new file mode 100644 index 0000000..f12db17 --- /dev/null +++ b/backend/app/services/agent_update.py @@ -0,0 +1,249 @@ +"""Request agent self-update, ingest lifecycle, SSH/WinRM fallback.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, 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, + AgentUpdateConfig, + get_effective_agent_update_config, +) +from app.services.agent_version import ( + is_agent_version_outdated, + latest_agent_versions_by_product, + parse_agent_version, +) +from app.services.linux_admin_settings import get_effective_linux_admin_config +from app.services.ssh_connect import ( + HostNotLinuxError, + HostTargetMissingError, + is_linux_host, + iter_ssh_targets, + run_ssh_monitor_update, +) +from app.services.win_admin_settings import get_effective_win_admin_config +from app.services.winrm_connect import ( + HostNotWindowsError, + HostTargetMissingError as WinRmHostTargetMissingError, + is_windows_host, + iter_winrm_targets, + run_winrm_on_host_targets, + run_winrm_rdp_monitor_update, +) + +AGENT_UPDATE_STARTED = "agent.update.started" +AGENT_UPDATE_SUCCESS = "agent.update.success" +AGENT_UPDATE_FAILED = "agent.update.failed" +AGENT_UPDATE_TYPES = frozenset( + {AGENT_UPDATE_STARTED, AGENT_UPDATE_SUCCESS, AGENT_UPDATE_FAILED} +) + + +class AgentUpdateNotAllowedError(Exception): + pass + + +def resolve_target_version(db: Session, host: Host, cfg: AgentUpdateConfig) -> str: + recommended = cfg.recommended_for_product(host.product or "") + if recommended: + return recommended + latest_map = latest_agent_versions_by_product(db) + return latest_map.get(host.product or "", "") or (host.product_version or "") + + +def host_version_status( + host: Host, + *, + cfg: AgentUpdateConfig, + latest_map: dict[str, str], +) -> str: + current = host.product_version + if not current or parse_agent_version(current) is None: + return "unknown" + recommended = cfg.recommended_for_product(host.product or "") + reference = recommended or latest_map.get(host.product or "") + if not reference: + return "unknown" + if is_agent_version_outdated(current, reference): + return "outdated" + return "ok" + + +def request_agent_update(db: Session, host: Host) -> Host: + cfg = get_effective_agent_update_config(db) + if not cfg.sac_managed: + raise AgentUpdateNotAllowedError( + "Agent updates from SAC are disabled (mode=gpo). Use GPO/manual update or switch to mode=sac." + ) + if not host.agent_instance_id: + raise AgentUpdateNotAllowedError("Host has no agent_instance_id (agent never connected via SAC)") + + target = resolve_target_version(db, host, cfg) + now = datetime.now(timezone.utc) + host.pending_agent_update = True + host.pending_update_requested_at = now + host.pending_update_target_version = target or None + host.agent_update_state = "requested" + host.agent_update_last_error = None + db.flush() + return host + + +def _clear_pending(host: Host) -> None: + host.pending_agent_update = False + host.pending_update_requested_at = None + host.pending_update_target_version = None + + +def process_agent_update_ingest(db: Session, host: Host, event_type: str, details: dict | None) -> None: + if event_type not in AGENT_UPDATE_TYPES: + return + + now = datetime.now(timezone.utc) + host.agent_update_last_at = now + details = details if isinstance(details, dict) else {} + + if event_type == AGENT_UPDATE_STARTED: + host.agent_update_state = "started" + return + + if event_type == AGENT_UPDATE_SUCCESS: + host.agent_update_state = "success" + host.agent_update_last_error = None + _clear_pending(host) + version = details.get("product_version") or details.get("version") + if isinstance(version, str) and version.strip(): + host.product_version = version.strip() + return + + if event_type == AGENT_UPDATE_FAILED: + host.agent_update_state = "failed" + err = details.get("error") or details.get("message") + host.agent_update_last_error = str(err)[:2000] if err else "agent.update.failed" + _clear_pending(host) + + +def run_linux_agent_update_fallback(host: Host, cfg_linux) -> tuple[bool, str, str | None]: + if not is_linux_host(host): + return False, "Host is not Linux", None + if not cfg_linux.configured: + return False, "Linux SSH admin is not configured", None + + last_message = "SSH fallback failed" + version: str | None = None + try: + targets = iter_ssh_targets(host) + except (HostNotLinuxError, HostTargetMissingError) as exc: + return False, str(exc), None + + for target in targets: + result = run_ssh_monitor_update(target=target, user=cfg_linux.user, password=cfg_linux.password) + last_message = result.message + if result.ok: + version = result.agent_version + return True, result.message, version + return False, last_message, version + + +def run_windows_agent_update_fallback(host: Host, cfg_win, script_path: str) -> tuple[bool, str, str | None]: + if not is_windows_host(host): + return False, "Host is not Windows", None + if not cfg_win.configured: + return False, "Windows domain admin is not configured", None + + try: + targets = iter_winrm_targets(host) + except (HostNotWindowsError, WinRmHostTargetMissingError) as exc: + return False, str(exc), None + + result, attempts = run_winrm_on_host_targets( + host, + user=cfg_win.user, + password=cfg_win.password, + action=lambda target: run_winrm_rdp_monitor_update( + target=target, + user=cfg_win.user, + password=cfg_win.password, + script_path=script_path, + ), + ) + if result is None: + return False, "WinRM fallback failed", None + message = result.message + if attempts: + message = f"{message} (пробовали: {', '.join(attempts)})" + version = None + if result.ok and result.stdout: + from app.services.ssh_connect import parse_ssh_monitor_version_text + + version = parse_ssh_monitor_version_text(result.stdout) + return result.ok, message, version + + +def execute_agent_update_fallback(db: Session, host: Host) -> tuple[bool, str, str | None]: + cfg = get_effective_agent_update_config(db) + product = (host.product or "").strip() + + if product == PRODUCT_SSH or is_linux_host(host): + linux_cfg = get_effective_linux_admin_config(db) + ok, message, version = run_linux_agent_update_fallback(host, linux_cfg) + elif product == PRODUCT_RDP or is_windows_host(host): + win_cfg = get_effective_win_admin_config(db) + ok, message, version = run_windows_agent_update_fallback(host, win_cfg, cfg.win_agent_update_script) + else: + return False, f"Unsupported product for fallback update: {product}", None + + now = datetime.now(timezone.utc) + host.agent_update_last_at = now + if ok: + host.agent_update_state = "success" + host.agent_update_last_error = None + _clear_pending(host) + if version: + host.product_version = version + if is_linux_host(host): + host.ssh_admin_ok = True + host.ssh_admin_checked_at = now + else: + host.agent_update_state = "failed" + host.agent_update_last_error = message[:2000] + + db.flush() + return ok, message, version + + +def process_agent_update_fallbacks(db: Session) -> list[dict]: + """Find stale pending updates and run SSH/WinRM fallback when enabled.""" + cfg = get_effective_agent_update_config(db) + if not cfg.sac_managed or not cfg.fallback_enabled: + return [] + + cutoff = datetime.now(timezone.utc) - timedelta(minutes=cfg.fallback_after_minutes) + rows = db.scalars( + select(Host).where( + Host.pending_agent_update.is_(True), + Host.pending_update_requested_at.isnot(None), + Host.pending_update_requested_at <= cutoff, + Host.agent_update_state.in_(("requested", "started")), + ) + ).all() + + results: list[dict] = [] + for host in rows: + ok, message, version = execute_agent_update_fallback(db, host) + results.append( + { + "host_id": host.id, + "hostname": host.hostname, + "ok": ok, + "message": message, + "product_version": version, + } + ) + return results diff --git a/backend/app/services/agent_update_settings.py b/backend/app/services/agent_update_settings.py new file mode 100644 index 0000000..4e07356 --- /dev/null +++ b/backend/app/services/agent_update_settings.py @@ -0,0 +1,162 @@ +"""Global agent update policy (DB overrides env).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings + +PRODUCT_RDP = "rdp-login-monitor" +PRODUCT_SSH = "ssh-monitor" +AGENT_UPDATE_MODES = frozenset({"gpo", "sac"}) + + +@dataclass(frozen=True) +class AgentUpdateConfig: + mode: str + fallback_enabled: bool + fallback_after_minutes: int + recommended_rdp_version: str + recommended_ssh_version: str + min_rdp_version: str + min_ssh_version: str + win_agent_update_script: str + source: str # env | db + + @property + def sac_managed(self) -> bool: + return self.mode == "sac" + + def recommended_for_product(self, product: str) -> str: + if product == PRODUCT_RDP: + return self.recommended_rdp_version + if product == PRODUCT_SSH: + return self.recommended_ssh_version + return "" + + def min_for_product(self, product: str) -> str: + if product == PRODUCT_RDP: + return self.min_rdp_version + if product == PRODUCT_SSH: + return self.min_ssh_version + return "" + + +def _agent_update_from_env() -> AgentUpdateConfig: + settings = get_settings() + mode = (settings.sac_agent_update_mode or "gpo").strip().lower() + if mode not in AGENT_UPDATE_MODES: + mode = "gpo" + return AgentUpdateConfig( + mode=mode, + fallback_enabled=settings.sac_agent_update_fallback_enabled, + fallback_after_minutes=max(1, int(settings.sac_agent_update_fallback_minutes)), + recommended_rdp_version=(settings.sac_agent_recommended_rdp_version or "").strip(), + recommended_ssh_version=(settings.sac_agent_recommended_ssh_version or "").strip(), + min_rdp_version=(settings.sac_agent_min_rdp_version or "").strip(), + min_ssh_version=(settings.sac_agent_min_ssh_version or "").strip(), + win_agent_update_script=(settings.sac_win_agent_update_script or "").strip(), + source="env", + ) + + +def _row_has_agent_update_values(row: UiSettings) -> bool: + return any( + [ + (row.agent_update_mode or "").strip() not in ("", "gpo"), + row.agent_update_fallback_enabled is not None, + row.agent_update_fallback_minutes not in (None, 15), + bool((row.agent_recommended_rdp_version or "").strip()), + bool((row.agent_recommended_ssh_version or "").strip()), + bool((row.agent_min_rdp_version or "").strip()), + bool((row.agent_min_ssh_version or "").strip()), + bool((row.win_agent_update_script or "").strip()), + ] + ) + + +def get_effective_agent_update_config(db: Session | None = None) -> AgentUpdateConfig: + if db is None: + from app.database import SessionLocal + + session = SessionLocal() + try: + return get_effective_agent_update_config(session) + finally: + session.close() + + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + env_cfg = _agent_update_from_env() + if row is None: + return env_cfg + + mode = (row.agent_update_mode or env_cfg.mode).strip().lower() + if mode not in AGENT_UPDATE_MODES: + mode = env_cfg.mode + + return AgentUpdateConfig( + mode=mode, + fallback_enabled=( + row.agent_update_fallback_enabled + if row.agent_update_fallback_enabled is not None + else env_cfg.fallback_enabled + ), + fallback_after_minutes=max( + 1, + int(row.agent_update_fallback_minutes or env_cfg.fallback_after_minutes), + ), + recommended_rdp_version=(row.agent_recommended_rdp_version or "").strip() + or env_cfg.recommended_rdp_version, + recommended_ssh_version=(row.agent_recommended_ssh_version or "").strip() + or env_cfg.recommended_ssh_version, + min_rdp_version=(row.agent_min_rdp_version or "").strip() or env_cfg.min_rdp_version, + min_ssh_version=(row.agent_min_ssh_version or "").strip() or env_cfg.min_ssh_version, + win_agent_update_script=(row.win_agent_update_script or "").strip() + or env_cfg.win_agent_update_script, + source="db" if _row_has_agent_update_values(row) else env_cfg.source, + ) + + +def upsert_agent_update_settings( + db: Session, + *, + mode: str | None = None, + fallback_enabled: bool | None = None, + fallback_after_minutes: int | None = None, + recommended_rdp_version: str | None = None, + recommended_ssh_version: str | None = None, + min_rdp_version: str | None = None, + min_ssh_version: str | None = None, + win_agent_update_script: str | None = None, +) -> AgentUpdateConfig: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + if row is None: + row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True) + db.add(row) + + if mode is not None: + normalized = mode.strip().lower() + if normalized not in AGENT_UPDATE_MODES: + raise ValueError(f"mode must be one of: {sorted(AGENT_UPDATE_MODES)}") + row.agent_update_mode = normalized + if fallback_enabled is not None: + row.agent_update_fallback_enabled = fallback_enabled + if fallback_after_minutes is not None: + row.agent_update_fallback_minutes = max(1, int(fallback_after_minutes)) + if recommended_rdp_version is not None: + row.agent_recommended_rdp_version = recommended_rdp_version.strip() or None + if recommended_ssh_version is not None: + row.agent_recommended_ssh_version = recommended_ssh_version.strip() or None + if min_rdp_version is not None: + row.agent_min_rdp_version = min_rdp_version.strip() or None + if min_ssh_version is not None: + row.agent_min_ssh_version = min_ssh_version.strip() or None + if win_agent_update_script is not None: + row.win_agent_update_script = win_agent_update_script.strip() or None + + db.commit() + db.refresh(row) + return get_effective_agent_update_config(db) diff --git a/backend/app/services/ingest.py b/backend/app/services/ingest.py index be50f76..f5ed5f8 100644 --- a/backend/app/services/ingest.py +++ b/backend/app/services/ingest.py @@ -5,6 +5,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.models import Event, Host +from app.services.agent_update import process_agent_update_ingest from app.services.daily_report_format import normalize_daily_report_details from app.services.event_severity_overrides import apply_severity_override from app.services.host_inventory import INVENTORY_EVENT_TYPE, process_inventory_ingest @@ -123,4 +124,6 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]: if raced is not None: return raced, False raise + + process_agent_update_ingest(db, host, payload.get("type", ""), details) return event, True diff --git a/backend/app/services/winrm_connect.py b/backend/app/services/winrm_connect.py index fe3c3c8..0ab33e1 100644 --- a/backend/app/services/winrm_connect.py +++ b/backend/app/services/winrm_connect.py @@ -140,6 +140,36 @@ def run_winrm_logoff(*, target: str, user: str, password: str, session_id: int) ) +def _winrm_rdp_update_remote_cmd(script_path: str) -> str: + script = script_path.strip() + if script: + return f'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "{script}"' + return ( + "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command " + '"& { $candidates = @(' + "(Join-Path $env:ProgramData 'LoginMonitor' 'Deploy-LoginMonitor.ps1')," + "(Join-Path $env:USERPROFILE 'Deploy-LoginMonitor.ps1')" + '); $p = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1; ' + "if (-not $p) { throw 'Deploy-LoginMonitor.ps1 not found' }; & $p }'" + ) + + +def run_winrm_rdp_monitor_update( + *, + target: str, + user: str, + password: str, + script_path: str = "", +) -> WinRmCmdResult: + return run_winrm_cmd( + target=target, + user=user, + password=password, + remote_cmd=_winrm_rdp_update_remote_cmd(script_path), + timeout_sec=900, + ) + + def run_winrm_on_host_targets( host: Host, *, diff --git a/backend/app/version.py b/backend/app/version.py index b939d43..04c985e 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.11.6" +APP_VERSION = "0.12.0" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_agent_update.py b/backend/tests/test_agent_update.py new file mode 100644 index 0000000..a5bd8a3 --- /dev/null +++ b/backend/tests/test_agent_update.py @@ -0,0 +1,208 @@ +"""Tests for agent update settings, poll, request and ingest lifecycle.""" + +import uuid +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +from app.models import Host +from app.services.agent_poll import build_agent_poll_payload +from app.services.agent_update import ( + AGENT_UPDATE_SUCCESS, + AgentUpdateNotAllowedError, + process_agent_update_ingest, + request_agent_update, +) +from app.services.agent_update_settings import upsert_agent_update_settings +from app.services.ingest import ingest_event +from tests.test_ingest import VALID_EVENT + + +def test_agent_update_settings_sac_mode(db_session): + cfg = upsert_agent_update_settings( + db_session, + mode="sac", + recommended_ssh_version="2.1.0-SAC", + fallback_after_minutes=10, + ) + assert cfg.mode == "sac" + assert cfg.recommended_ssh_version == "2.1.0-SAC" + assert cfg.fallback_after_minutes == 10 + + +def test_request_agent_update_sets_pending(db_session): + upsert_agent_update_settings(db_session, mode="sac", recommended_ssh_version="2.1.0-SAC") + host = Host( + hostname="linux-1", + os_family="linux", + product="ssh-monitor", + product_version="2.0.0-SAC", + agent_instance_id="agent-uuid-1", + ) + db_session.add(host) + db_session.commit() + + request_agent_update(db_session, host) + assert host.pending_agent_update is True + assert host.pending_update_target_version == "2.1.0-SAC" + assert host.agent_update_state == "requested" + + +def test_request_agent_update_blocked_in_gpo_mode(db_session): + upsert_agent_update_settings(db_session, mode="gpo") + host = Host( + hostname="linux-2", + os_family="linux", + product="ssh-monitor", + agent_instance_id="agent-uuid-2", + ) + db_session.add(host) + db_session.commit() + + try: + request_agent_update(db_session, host) + raise AssertionError("expected AgentUpdateNotAllowedError") + except AgentUpdateNotAllowedError: + pass + + +def test_poll_payload_includes_update_and_config(db_session): + upsert_agent_update_settings(db_session, mode="sac") + host = Host( + hostname="win-1", + os_family="windows", + product="rdp-login-monitor", + agent_instance_id="agent-uuid-3", + pending_agent_update=True, + pending_update_target_version="2.1.0-SAC", + agent_config={"ServerDisplayName": "Test PC"}, + agent_config_revision=3, + ) + db_session.add(host) + db_session.commit() + + payload = build_agent_poll_payload(db_session, host) + assert payload["config_revision"] == 3 + assert payload["config"]["settings"]["ServerDisplayName"] == "Test PC" + assert payload["update"]["requested"] is True + assert payload["update"]["target_version"] == "2.1.0-SAC" + assert payload["update"]["source"] == "sac" + + +def test_ingest_agent_update_success_clears_pending(db_session): + host = Host( + hostname="linux-3", + os_family="linux", + product="ssh-monitor", + agent_instance_id="agent-uuid-4", + pending_agent_update=True, + pending_update_target_version="2.1.0-SAC", + ) + db_session.add(host) + db_session.commit() + + process_agent_update_ingest( + db_session, + host, + AGENT_UPDATE_SUCCESS, + {"product_version": "2.1.0-SAC"}, + ) + assert host.pending_agent_update is False + assert host.product_version == "2.1.0-SAC" + assert host.agent_update_state == "success" + + +def test_api_request_agent_update(jwt_headers, client, db_session): + upsert_agent_update_settings(db_session, mode="sac", recommended_ssh_version="2.1.0-SAC") + host = Host( + hostname="api-linux", + os_family="linux", + product="ssh-monitor", + agent_instance_id="agent-api-1", + ) + db_session.add(host) + db_session.commit() + + response = client.post(f"/api/v1/hosts/{host.id}/actions/request-agent-update", headers=jwt_headers) + assert response.status_code == 200 + body = response.json() + assert body["pending_agent_update"] is True + assert body["target_version"] == "2.1.0-SAC" + + +def test_api_agent_poll_extended(auth_headers, client, db_session): + upsert_agent_update_settings(db_session, mode="sac") + host = Host( + hostname="poll-host", + os_family="linux", + product="ssh-monitor", + agent_instance_id="poll-agent-id", + pending_agent_update=True, + pending_update_target_version="2.1.0-SAC", + ) + db_session.add(host) + db_session.commit() + + response = client.get( + "/api/v1/agent/commands", + params={"agent_instance_id": "poll-agent-id"}, + headers=auth_headers, + ) + assert response.status_code == 200 + body = response.json() + assert body["update"]["requested"] is True + assert body["commands"] == [] + + +def test_api_patch_agent_config(jwt_headers, client, db_session): + host = Host( + hostname="cfg-host", + os_family="windows", + product="rdp-login-monitor", + ) + db_session.add(host) + db_session.commit() + + response = client.patch( + f"/api/v1/hosts/{host.id}/agent-config", + json={"settings": {"ServerDisplayName": "UNMS Kalina", "GetInventory": True}}, + headers=jwt_headers, + ) + assert response.status_code == 200 + body = response.json() + assert body["config_revision"] == 1 + assert body["settings"]["ServerDisplayName"] == "UNMS Kalina" + + +def test_api_agent_update_fallback_ssh(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() + + host = Host( + hostname="fb-linux", + os_family="linux", + product="ssh-monitor", + ipv4="10.10.36.9", + ) + db_session.add(host) + db_session.commit() + + with patch("app.services.agent_update.run_ssh_monitor_update") as mock_update: + from app.services.ssh_connect import SshCommandResult + + mock_update.return_value = SshCommandResult( + ok=True, + message="updated", + target="fb-linux", + agent_version="2.1.0-SAC", + ) + response = client.post( + f"/api/v1/hosts/{host.id}/actions/agent-update-fallback", + headers=jwt_headers, + ) + + assert response.status_code == 200 + assert response.json()["ok"] is True + assert response.json()["product_version"] == "2.1.0-SAC" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index adeee00..7f78e9a 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL def test_version_constants(): - assert APP_VERSION == "0.11.6" + assert APP_VERSION == "0.12.0" assert APP_NAME == "Security Alert Center" - assert APP_VERSION_LABEL == "Security Alert Center v.0.11.6" + assert APP_VERSION_LABEL == "Security Alert Center v.0.12.0" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 42975f8..0f3fa01 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -230,6 +230,8 @@ export interface HostSummary { last_daily_report_at: string | null; last_inventory_at?: string | null; agent_status: "online" | "stale" | "unknown"; + version_status?: "ok" | "outdated" | "unknown"; + pending_agent_update?: boolean; ssh_admin_ok?: boolean | null; ssh_admin_checked_at?: string | null; } @@ -242,6 +244,13 @@ export interface HostDetail extends HostSummary { inventory: Record | null; inventory_updated_at: string | null; created_at: string | null; + pending_update_requested_at?: string | null; + pending_update_target_version?: string | null; + agent_config_revision?: number; + agent_config?: Record | null; + agent_update_state?: string | null; + agent_update_last_at?: string | null; + agent_update_last_error?: string | null; } export function fetchHost(id: number): Promise { @@ -495,6 +504,66 @@ export function updateHostAgentViaSsh(hostId: number): Promise { + return apiFetch( + `/api/v1/hosts/${hostId}/actions/request-agent-update`, + { method: "POST" }, + ); +} + +export function runHostAgentUpdateFallback(hostId: number): Promise { + return apiFetch(`/api/v1/hosts/${hostId}/actions/agent-update-fallback`, { + method: "POST", + }); +} + +export interface HostAgentConfigResponse { + config_revision: number; + settings: Record; +} + +export function patchHostAgentConfig( + hostId: number, + settings: Record, +): Promise { + return apiFetch(`/api/v1/hosts/${hostId}/agent-config`, { + method: "PATCH", + body: JSON.stringify({ settings }), + }); +} + +export interface AgentUpdateSettings { + mode: "gpo" | "sac"; + fallback_enabled: boolean; + fallback_after_minutes: number; + recommended_rdp_version: string | null; + recommended_ssh_version: string | null; + min_rdp_version: string | null; + min_ssh_version: string | null; + win_agent_update_script: string | null; + source: string; +} + +export function fetchAgentUpdateSettings(): Promise { + return apiFetch("/api/v1/settings/agent-updates"); +} + +export function saveAgentUpdateSettings( + payload: Partial, +): Promise { + return apiFetch("/api/v1/settings/agent-updates", { + method: "PUT", + body: JSON.stringify(payload), + }); +} + export interface DashboardSummary { events_last_24h: number; hosts_total: number; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 257bf3e..10d5e6b 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.11.6"; +export const APP_VERSION = "0.12.0"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/HostDetailView.vue b/frontend/src/views/HostDetailView.vue index d154b73..0acf939 100644 --- a/frontend/src/views/HostDetailView.vue +++ b/frontend/src/views/HostDetailView.vue @@ -20,7 +20,23 @@
Версия агента
-
{{ host.product_version || "—" }}
+
+ {{ host.product_version || "—" }} + (устарела) + обновление запрошено +
+
+
+
Обновление
+
+ {{ host.agent_update_state }} + → {{ host.pending_update_target_version }} + ({{ formatDt(host.agent_update_last_at) }}) +
+
+
+
Ошибка обновления
+
{{ host.agent_update_last_error }}
OS
@@ -57,6 +73,25 @@ {{ testingWinRm ? "Проверка…" : "Проверить WinRM (admin)" }}

{{ winRmTestMessage }}

+
+ + +
+

{{ agentControlMessage }}

@@ -81,9 +116,26 @@ :disabled="updatingAgent" @click="runAgentUpdate" > - {{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor" }} + {{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor (SSH)" }} + + +
+

{{ agentControlMessage }}

{{ sshTestMessage }}

{{ sshTestOutput }}
@@ -95,6 +147,29 @@
+
+

Desired config (poll агента)

+

Агент заберёт настройки при следующем poll. Revision: {{ host.agent_config_revision ?? 0 }}

+
+ + + + +

{{ agentConfigMessage }}

+
+
+

Железо и ПО

@@ -204,7 +279,7 @@