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.
This commit is contained in:
@@ -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")
|
||||||
@@ -7,11 +7,10 @@ from sqlalchemy.orm import Session
|
|||||||
from app.auth.api_key import get_api_key_auth
|
from app.auth.api_key import get_api_key_auth
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.agent_commands import (
|
from app.services.agent_commands import (
|
||||||
command_to_dict,
|
|
||||||
complete_command,
|
complete_command,
|
||||||
get_command_for_host_poll,
|
|
||||||
resolve_host_by_agent_instance_id,
|
resolve_host_by_agent_instance_id,
|
||||||
)
|
)
|
||||||
|
from app.services.agent_poll import build_agent_poll_payload
|
||||||
|
|
||||||
router = APIRouter(prefix="/agent", tags=["agent"])
|
router = APIRouter(prefix="/agent", tags=["agent"])
|
||||||
|
|
||||||
@@ -23,7 +22,10 @@ class AgentCommandResultBody(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AgentCommandsPollResponse(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)
|
@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)
|
host = resolve_host_by_agent_instance_id(db, agent_instance_id)
|
||||||
if host is None:
|
if host is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown agent_instance_id")
|
raise HTTPException(status_code=404, detail="Unknown agent_instance_id")
|
||||||
pending = get_command_for_host_poll(db, host)
|
payload = build_agent_poll_payload(db, host)
|
||||||
return AgentCommandsPollResponse(
|
return AgentCommandsPollResponse(**payload)
|
||||||
commands=[command_to_dict(c, include_run_as=True) for c in pending]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/commands/{command_uuid}/result")
|
@router.post("/commands/{command_uuid}/result")
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ from app.database import get_db
|
|||||||
from app.models import Event, Host
|
from app.models import Event, Host
|
||||||
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
|
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
|
||||||
from app.services.agent_version import latest_agent_versions_by_product
|
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.host_delete import delete_host_and_related
|
||||||
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
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.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)
|
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
|
||||||
report_map = max_daily_report_time_by_host(db)
|
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] = []
|
items: list[HostSummary] = []
|
||||||
for host in rows:
|
for host in rows:
|
||||||
@@ -107,6 +117,8 @@ def list_hosts(
|
|||||||
agent_status=compute_agent_status(
|
agent_status=compute_agent_status(
|
||||||
last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes
|
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)
|
select(func.count()).select_from(Event).where(Event.host_id == host.id)
|
||||||
)
|
)
|
||||||
last_hb = hb_map.get(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(
|
return HostDetail(
|
||||||
id=host.id,
|
id=host.id,
|
||||||
hostname=host.hostname,
|
hostname=host.hostname,
|
||||||
@@ -147,6 +161,8 @@ def _host_detail_from_model(
|
|||||||
last_daily_report_at=report_map.get(host.id),
|
last_daily_report_at=report_map.get(host.id),
|
||||||
last_inventory_at=host.inventory_updated_at,
|
last_inventory_at=host.inventory_updated_at,
|
||||||
agent_status=compute_agent_status(last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes),
|
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,
|
agent_instance_id=host.agent_instance_id,
|
||||||
tags=host.tags if isinstance(host.tags, list) else [],
|
tags=host.tags if isinstance(host.tags, list) else [],
|
||||||
use_sac_mode=host.use_sac_mode,
|
use_sac_mode=host.use_sac_mode,
|
||||||
@@ -155,6 +171,13 @@ def _host_detail_from_model(
|
|||||||
created_at=host.created_at,
|
created_at=host.created_at,
|
||||||
ssh_admin_ok=host.ssh_admin_ok,
|
ssh_admin_ok=host.ssh_admin_ok,
|
||||||
ssh_admin_checked_at=host.ssh_admin_checked_at,
|
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
|
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(
|
def _ssh_action_response(
|
||||||
result: SshCommandResult,
|
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)
|
@router.delete("/{host_id}", response_model=HostDeleteResponse)
|
||||||
def delete_host(
|
def delete_host(
|
||||||
host_id: int,
|
host_id: int,
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ from app.services.win_admin_settings import (
|
|||||||
get_effective_win_admin_config,
|
get_effective_win_admin_config,
|
||||||
upsert_win_admin_settings,
|
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 (
|
from app.services.winrm_connect import (
|
||||||
HostNotWindowsError,
|
HostNotWindowsError,
|
||||||
HostTargetMissingError,
|
HostTargetMissingError,
|
||||||
@@ -540,3 +544,71 @@ def update_linux_admin_settings(
|
|||||||
password_hint=_mask_secret(cfg.password),
|
password_hint=_mask_secret(cfg.password),
|
||||||
source=cfg.source,
|
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)
|
||||||
|
|||||||
@@ -115,6 +115,16 @@ class Settings(BaseSettings):
|
|||||||
sac_linux_admin_user: str = ""
|
sac_linux_admin_user: str = ""
|
||||||
sac_linux_admin_password: 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)
|
# Retention (app.jobs.retention / systemd timer)
|
||||||
sac_events_retention_days: int = 90
|
sac_events_retention_days: int = 90
|
||||||
sac_problems_retention_days: int = 180
|
sac_problems_retention_days: int = 180
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ def _run_scan_once() -> None:
|
|||||||
try:
|
try:
|
||||||
results = run_host_silence_scan(db)
|
results = run_host_silence_scan(db)
|
||||||
notified = dispatch_host_silence_notifications(db, results)
|
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()
|
db.commit()
|
||||||
if results:
|
if results:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -44,6 +47,12 @@ def _run_scan_once() -> None:
|
|||||||
sum(1 for r in results if r.created),
|
sum(1 for r in results if r.created),
|
||||||
notified,
|
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:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ class Host(Base):
|
|||||||
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
|
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
|
||||||
ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
||||||
ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=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())
|
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())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|||||||
@@ -17,3 +17,11 @@ class UiSettings(Base):
|
|||||||
win_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True)
|
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_user: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
linux_admin_password: Mapped[str | None] = mapped_column(Text, 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)
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ class HostSummary(BaseModel):
|
|||||||
last_daily_report_at: datetime | None = None
|
last_daily_report_at: datetime | None = None
|
||||||
last_inventory_at: datetime | None = None
|
last_inventory_at: datetime | None = None
|
||||||
agent_status: str = "unknown"
|
agent_status: str = "unknown"
|
||||||
|
version_status: str = "unknown"
|
||||||
|
pending_agent_update: bool = False
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -32,6 +34,13 @@ class HostDetail(HostSummary):
|
|||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
ssh_admin_ok: bool | None = None
|
ssh_admin_ok: bool | None = None
|
||||||
ssh_admin_checked_at: datetime | 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):
|
class HostListResponse(BaseModel):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -5,6 +5,7 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Event, Host
|
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.daily_report_format import normalize_daily_report_details
|
||||||
from app.services.event_severity_overrides import apply_severity_override
|
from app.services.event_severity_overrides import apply_severity_override
|
||||||
from app.services.host_inventory import INVENTORY_EVENT_TYPE, process_inventory_ingest
|
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:
|
if raced is not None:
|
||||||
return raced, False
|
return raced, False
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
process_agent_update_ingest(db, host, payload.get("type", ""), details)
|
||||||
return event, True
|
return event, True
|
||||||
|
|||||||
@@ -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(
|
def run_winrm_on_host_targets(
|
||||||
host: Host,
|
host: Host,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.11.6"
|
APP_VERSION = "0.12.0"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
|||||||
|
|
||||||
|
|
||||||
def test_version_constants():
|
def test_version_constants():
|
||||||
assert APP_VERSION == "0.11.6"
|
assert APP_VERSION == "0.12.0"
|
||||||
assert APP_NAME == "Security Alert Center"
|
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"
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ export interface HostSummary {
|
|||||||
last_daily_report_at: string | null;
|
last_daily_report_at: string | null;
|
||||||
last_inventory_at?: string | null;
|
last_inventory_at?: string | null;
|
||||||
agent_status: "online" | "stale" | "unknown";
|
agent_status: "online" | "stale" | "unknown";
|
||||||
|
version_status?: "ok" | "outdated" | "unknown";
|
||||||
|
pending_agent_update?: boolean;
|
||||||
ssh_admin_ok?: boolean | null;
|
ssh_admin_ok?: boolean | null;
|
||||||
ssh_admin_checked_at?: string | null;
|
ssh_admin_checked_at?: string | null;
|
||||||
}
|
}
|
||||||
@@ -242,6 +244,13 @@ export interface HostDetail extends HostSummary {
|
|||||||
inventory: Record<string, unknown> | null;
|
inventory: Record<string, unknown> | null;
|
||||||
inventory_updated_at: string | null;
|
inventory_updated_at: string | null;
|
||||||
created_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<string, unknown> | null;
|
||||||
|
agent_update_state?: string | null;
|
||||||
|
agent_update_last_at?: string | null;
|
||||||
|
agent_update_last_error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchHost(id: number): Promise<HostDetail> {
|
export function fetchHost(id: number): Promise<HostDetail> {
|
||||||
@@ -495,6 +504,66 @@ export function updateHostAgentViaSsh(hostId: number): Promise<HostSshActionResu
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HostAgentUpdateRequestResult {
|
||||||
|
status: string;
|
||||||
|
pending_agent_update: boolean;
|
||||||
|
target_version: string | null;
|
||||||
|
agent_update_state: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestHostAgentUpdate(hostId: number): Promise<HostAgentUpdateRequestResult> {
|
||||||
|
return apiFetch<HostAgentUpdateRequestResult>(
|
||||||
|
`/api/v1/hosts/${hostId}/actions/request-agent-update`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runHostAgentUpdateFallback(hostId: number): Promise<HostSshActionResult> {
|
||||||
|
return apiFetch<HostSshActionResult>(`/api/v1/hosts/${hostId}/actions/agent-update-fallback`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostAgentConfigResponse {
|
||||||
|
config_revision: number;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchHostAgentConfig(
|
||||||
|
hostId: number,
|
||||||
|
settings: Record<string, unknown>,
|
||||||
|
): Promise<HostAgentConfigResponse> {
|
||||||
|
return apiFetch<HostAgentConfigResponse>(`/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<AgentUpdateSettings> {
|
||||||
|
return apiFetch<AgentUpdateSettings>("/api/v1/settings/agent-updates");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAgentUpdateSettings(
|
||||||
|
payload: Partial<AgentUpdateSettings>,
|
||||||
|
): Promise<AgentUpdateSettings> {
|
||||||
|
return apiFetch<AgentUpdateSettings>("/api/v1/settings/agent-updates", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
events_last_24h: number;
|
events_last_24h: number;
|
||||||
hosts_total: number;
|
hosts_total: number;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||||
export const APP_NAME = "Security Alert Center";
|
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}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -20,7 +20,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="detail-field">
|
<div class="detail-field">
|
||||||
<dt>Версия агента</dt>
|
<dt>Версия агента</dt>
|
||||||
<dd>{{ host.product_version || "—" }}</dd>
|
<dd>
|
||||||
|
<span :class="versionStatusClass">{{ host.product_version || "—" }}</span>
|
||||||
|
<span v-if="host.version_status === 'outdated'" class="muted version-hint"> (устарела)</span>
|
||||||
|
<span v-if="host.pending_agent_update" class="pending-update-badge">обновление запрошено</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="host.agent_update_state" class="detail-field">
|
||||||
|
<dt>Обновление</dt>
|
||||||
|
<dd>
|
||||||
|
{{ host.agent_update_state }}
|
||||||
|
<span v-if="host.pending_update_target_version"> → {{ host.pending_update_target_version }}</span>
|
||||||
|
<span v-if="host.agent_update_last_at" class="muted"> ({{ formatDt(host.agent_update_last_at) }})</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="host.agent_update_last_error" class="detail-field">
|
||||||
|
<dt>Ошибка обновления</dt>
|
||||||
|
<dd class="error-inline">{{ host.agent_update_last_error }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-field">
|
<div class="detail-field">
|
||||||
<dt>OS</dt>
|
<dt>OS</dt>
|
||||||
@@ -57,6 +73,25 @@
|
|||||||
{{ testingWinRm ? "Проверка…" : "Проверить WinRM (admin)" }}
|
{{ testingWinRm ? "Проверка…" : "Проверить WinRM (admin)" }}
|
||||||
</button>
|
</button>
|
||||||
<p v-if="winRmTestMessage" :class="winRmTestOk ? 'success' : 'error'">{{ winRmTestMessage }}</p>
|
<p v-if="winRmTestMessage" :class="winRmTestOk ? 'success' : 'error'">{{ winRmTestMessage }}</p>
|
||||||
|
<div class="host-actions-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="requestingAgentUpdate"
|
||||||
|
@click="runRequestAgentUpdate"
|
||||||
|
>
|
||||||
|
{{ requestingAgentUpdate ? "Запрос…" : "Запросить обновление (SAC)" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="runningAgentFallback"
|
||||||
|
@click="runAgentFallback"
|
||||||
|
>
|
||||||
|
{{ runningAgentFallback ? "Обновление…" : "Обновить через WinRM" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="agentControlMessage" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isLinuxHost" class="host-actions">
|
<div v-if="isLinuxHost" class="host-actions">
|
||||||
<div class="host-actions-row">
|
<div class="host-actions-row">
|
||||||
@@ -81,9 +116,26 @@
|
|||||||
:disabled="updatingAgent"
|
:disabled="updatingAgent"
|
||||||
@click="runAgentUpdate"
|
@click="runAgentUpdate"
|
||||||
>
|
>
|
||||||
{{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor" }}
|
{{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor (SSH)" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="requestingAgentUpdate"
|
||||||
|
@click="runRequestAgentUpdate"
|
||||||
|
>
|
||||||
|
{{ requestingAgentUpdate ? "Запрос…" : "Запросить обновление (SAC)" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="runningAgentFallback"
|
||||||
|
@click="runAgentFallback"
|
||||||
|
>
|
||||||
|
{{ runningAgentFallback ? "Fallback…" : "Fallback SSH" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="agentControlMessage" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
|
||||||
<div v-if="showSshTestFeedback && (sshTestMessage || sshTestOutput)" class="host-action-feedback">
|
<div v-if="showSshTestFeedback && (sshTestMessage || sshTestOutput)" class="host-action-feedback">
|
||||||
<p :class="sshTestOk ? 'success' : 'error'">{{ sshTestMessage }}</p>
|
<p :class="sshTestOk ? 'success' : 'error'">{{ sshTestMessage }}</p>
|
||||||
<pre v-if="sshTestOutput" class="host-ssh-output">{{ sshTestOutput }}</pre>
|
<pre v-if="sshTestOutput" class="host-ssh-output">{{ sshTestOutput }}</pre>
|
||||||
@@ -95,6 +147,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section v-if="showAgentConfig" class="card host-agent-config">
|
||||||
|
<h2>Desired config (poll агента)</h2>
|
||||||
|
<p class="muted">Агент заберёт настройки при следующем poll. Revision: {{ host.agent_config_revision ?? 0 }}</p>
|
||||||
|
<form class="agent-config-form" @submit.prevent="saveAgentConfig">
|
||||||
|
<label v-if="isWindowsHost" class="config-field">
|
||||||
|
ServerDisplayName
|
||||||
|
<input v-model="configForm.serverDisplayName" type="text" />
|
||||||
|
</label>
|
||||||
|
<label v-if="isLinuxHost" class="config-field">
|
||||||
|
SERVER_DISPLAY_NAME
|
||||||
|
<input v-model="configForm.serverDisplayName" type="text" />
|
||||||
|
</label>
|
||||||
|
<label v-if="isWindowsHost" class="config-inline">
|
||||||
|
<input v-model="configForm.getInventory" type="checkbox" />
|
||||||
|
GetInventory
|
||||||
|
</label>
|
||||||
|
<button type="submit" :disabled="savingAgentConfig">
|
||||||
|
{{ savingAgentConfig ? "Сохранение…" : "Сохранить config" }}
|
||||||
|
</button>
|
||||||
|
<p v-if="agentConfigMessage" :class="agentConfigOk ? 'success' : 'error'">{{ agentConfigMessage }}</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-if="inventory" class="host-inventory card">
|
<section v-if="inventory" class="host-inventory card">
|
||||||
<h2>Железо и ПО</h2>
|
<h2>Железо и ПО</h2>
|
||||||
<div v-if="windowsInfo" class="inv-block">
|
<div v-if="windowsInfo" class="inv-block">
|
||||||
@@ -204,7 +279,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
||||||
import {
|
import {
|
||||||
@@ -214,6 +289,9 @@ import {
|
|||||||
testHostWinRm,
|
testHostWinRm,
|
||||||
testHostSsh,
|
testHostSsh,
|
||||||
updateHostAgentViaSsh,
|
updateHostAgentViaSsh,
|
||||||
|
requestHostAgentUpdate,
|
||||||
|
runHostAgentUpdateFallback,
|
||||||
|
patchHostAgentConfig,
|
||||||
type EventListResponse,
|
type EventListResponse,
|
||||||
type HostDetail,
|
type HostDetail,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
@@ -244,6 +322,17 @@ const sshTestOutput = ref("");
|
|||||||
const agentUpdateMessage = ref("");
|
const agentUpdateMessage = ref("");
|
||||||
const agentUpdateOk = ref(false);
|
const agentUpdateOk = ref(false);
|
||||||
const agentUpdateOutput = ref("");
|
const agentUpdateOutput = ref("");
|
||||||
|
const requestingAgentUpdate = ref(false);
|
||||||
|
const runningAgentFallback = ref(false);
|
||||||
|
const agentControlMessage = ref("");
|
||||||
|
const agentControlOk = ref(false);
|
||||||
|
const savingAgentConfig = ref(false);
|
||||||
|
const agentConfigMessage = ref("");
|
||||||
|
const agentConfigOk = ref(false);
|
||||||
|
const configForm = reactive({
|
||||||
|
serverDisplayName: "",
|
||||||
|
getInventory: true,
|
||||||
|
});
|
||||||
let sshTestHideTimer: ReturnType<typeof setTimeout> | null = null;
|
let sshTestHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let sshProbeSeq = 0;
|
let sshProbeSeq = 0;
|
||||||
let refreshingLive = false;
|
let refreshingLive = false;
|
||||||
@@ -278,6 +367,23 @@ const sshVerifiedTitle = computed(() => {
|
|||||||
return "SSH доступен";
|
return "SSH доступен";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const versionStatusClass = computed(() => {
|
||||||
|
const status = host.value?.version_status;
|
||||||
|
if (status === "outdated") return "agent-version-outdated";
|
||||||
|
if (status === "ok") return "agent-version-ok";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const showAgentConfig = computed(() => isWindowsHost.value || isLinuxHost.value);
|
||||||
|
|
||||||
|
function syncConfigFormFromHost(detail: HostDetail) {
|
||||||
|
const cfg = detail.agent_config ?? {};
|
||||||
|
configForm.serverDisplayName = String(
|
||||||
|
cfg.ServerDisplayName ?? cfg.SERVER_DISPLAY_NAME ?? "",
|
||||||
|
);
|
||||||
|
configForm.getInventory = cfg.GetInventory !== false;
|
||||||
|
}
|
||||||
|
|
||||||
const inventory = computed(() => host.value?.inventory ?? null);
|
const inventory = computed(() => host.value?.inventory ?? null);
|
||||||
|
|
||||||
const windowsFields = computed(() => {
|
const windowsFields = computed(() => {
|
||||||
@@ -467,9 +573,75 @@ function formatSshOutput(result: { stdout: string | null; stderr: string | null;
|
|||||||
|
|
||||||
function applyHostDetail(detail: HostDetail) {
|
function applyHostDetail(detail: HostDetail) {
|
||||||
host.value = detail;
|
host.value = detail;
|
||||||
|
syncConfigFormFromHost(detail);
|
||||||
emitHostPatch(detail);
|
emitHostPatch(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runRequestAgentUpdate() {
|
||||||
|
requestingAgentUpdate.value = true;
|
||||||
|
agentControlMessage.value = "";
|
||||||
|
try {
|
||||||
|
const result = await requestHostAgentUpdate(hostId.value);
|
||||||
|
agentControlOk.value = true;
|
||||||
|
agentControlMessage.value = result.target_version
|
||||||
|
? `Запрошено обновление до ${result.target_version}`
|
||||||
|
: "Запрошено обновление агента";
|
||||||
|
await loadHost();
|
||||||
|
} catch (e) {
|
||||||
|
agentControlOk.value = false;
|
||||||
|
agentControlMessage.value = e instanceof Error ? e.message : "Не удалось запросить обновление";
|
||||||
|
} finally {
|
||||||
|
requestingAgentUpdate.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAgentFallback() {
|
||||||
|
runningAgentFallback.value = true;
|
||||||
|
agentControlMessage.value = "";
|
||||||
|
try {
|
||||||
|
const result = await runHostAgentUpdateFallback(hostId.value);
|
||||||
|
agentControlOk.value = result.ok;
|
||||||
|
agentControlMessage.value = result.message;
|
||||||
|
if (result.ok && result.product_version && host.value) {
|
||||||
|
host.value = { ...host.value, product_version: result.product_version };
|
||||||
|
emitHostPatch(host.value);
|
||||||
|
}
|
||||||
|
await loadHost();
|
||||||
|
} catch (e) {
|
||||||
|
agentControlOk.value = false;
|
||||||
|
agentControlMessage.value = e instanceof Error ? e.message : "Ошибка fallback-обновления";
|
||||||
|
} finally {
|
||||||
|
runningAgentFallback.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAgentConfig() {
|
||||||
|
savingAgentConfig.value = true;
|
||||||
|
agentConfigMessage.value = "";
|
||||||
|
const settings: Record<string, unknown> = {};
|
||||||
|
if (isWindowsHost.value) {
|
||||||
|
if (configForm.serverDisplayName.trim()) {
|
||||||
|
settings.ServerDisplayName = configForm.serverDisplayName.trim();
|
||||||
|
}
|
||||||
|
settings.GetInventory = configForm.getInventory;
|
||||||
|
} else if (isLinuxHost.value) {
|
||||||
|
if (configForm.serverDisplayName.trim()) {
|
||||||
|
settings.SERVER_DISPLAY_NAME = configForm.serverDisplayName.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await patchHostAgentConfig(hostId.value, settings);
|
||||||
|
agentConfigOk.value = true;
|
||||||
|
agentConfigMessage.value = `Config сохранён (revision ${result.config_revision})`;
|
||||||
|
await loadHost();
|
||||||
|
} catch (e) {
|
||||||
|
agentConfigOk.value = false;
|
||||||
|
agentConfigMessage.value = e instanceof Error ? e.message : "Ошибка сохранения config";
|
||||||
|
} finally {
|
||||||
|
savingAgentConfig.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadHost() {
|
async function loadHost() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@@ -636,4 +808,49 @@ watch(
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-version-outdated {
|
||||||
|
color: #f0883e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-version-ok {
|
||||||
|
color: #3fb950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-update-badge {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #58a6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-hint {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-inline {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-agent-config {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-config-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-width: 28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ function agentLabel(status: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isVersionOutdated(h: HostSummary): boolean {
|
function isVersionOutdated(h: HostSummary): boolean {
|
||||||
|
if (h.version_status === "outdated") return true;
|
||||||
|
if (h.version_status === "ok") return false;
|
||||||
const latest = data.value?.latest_agent_versions?.[h.product];
|
const latest = data.value?.latest_agent_versions?.[h.product];
|
||||||
return isAgentVersionOutdated(h.product_version, latest);
|
return isAgentVersionOutdated(h.product_version, latest);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,56 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card settings-card settings-policy">
|
||||||
|
<h2>Обновления агентов</h2>
|
||||||
|
<p class="settings-hint settings-hint-top">
|
||||||
|
Режим <code>gpo</code> — обновления вручную/GPO; SAC только показывает устаревшие версии.
|
||||||
|
Режим <code>sac</code> — «Запросить обновление» ставит флаг в poll; агент self-update;
|
||||||
|
через {{ agentUpdateForm.fallback_after_minutes }} мин без ответа — fallback SSH/WinRM с SAC.
|
||||||
|
</p>
|
||||||
|
<form class="settings-form" @submit.prevent="saveAgentUpdateSettingsForm">
|
||||||
|
<label class="settings-field">
|
||||||
|
Режим
|
||||||
|
<select v-model="agentUpdateForm.mode">
|
||||||
|
<option value="gpo">GPO / ручной</option>
|
||||||
|
<option value="sac">SAC (poll + fallback)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="settings-field settings-inline">
|
||||||
|
<input v-model="agentUpdateForm.fallback_enabled" type="checkbox" />
|
||||||
|
Авто-fallback SSH/WinRM после таймаута
|
||||||
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
Fallback через (мин)
|
||||||
|
<input v-model.number="agentUpdateForm.fallback_after_minutes" type="number" min="1" max="1440" />
|
||||||
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
Recommended RDP-login-monitor
|
||||||
|
<input v-model="agentUpdateForm.recommended_rdp_version" type="text" placeholder="2.1.0-SAC" />
|
||||||
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
Recommended ssh-monitor
|
||||||
|
<input v-model="agentUpdateForm.recommended_ssh_version" type="text" placeholder="2.1.0-SAC" />
|
||||||
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
WinRM: путь к Deploy-LoginMonitor.ps1 (fallback)
|
||||||
|
<input
|
||||||
|
v-model="agentUpdateForm.win_agent_update_script"
|
||||||
|
type="text"
|
||||||
|
placeholder="C:\ProgramData\LoginMonitor\Deploy-LoginMonitor.ps1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p v-if="agentUpdateLoaded" class="settings-meta">
|
||||||
|
Источник: <code>{{ agentUpdateLoaded.source }}</code>
|
||||||
|
</p>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button type="submit" :disabled="savingAgentUpdate">
|
||||||
|
{{ savingAgentUpdate ? "Сохранение…" : "Сохранить обновления агентов" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card settings-card settings-policy">
|
<section class="card settings-card settings-policy">
|
||||||
<h2>Правило оповещений</h2>
|
<h2>Правило оповещений</h2>
|
||||||
<p class="settings-hint settings-hint-top">
|
<p class="settings-hint settings-hint-top">
|
||||||
@@ -514,6 +564,8 @@ import {
|
|||||||
fetchUsers,
|
fetchUsers,
|
||||||
fetchWinAdminSettings,
|
fetchWinAdminSettings,
|
||||||
fetchLinuxAdminSettings,
|
fetchLinuxAdminSettings,
|
||||||
|
fetchAgentUpdateSettings,
|
||||||
|
saveAgentUpdateSettings,
|
||||||
revokeMobileDevice,
|
revokeMobileDevice,
|
||||||
deleteMobileDevice,
|
deleteMobileDevice,
|
||||||
revokeMobileEnrollmentCode,
|
revokeMobileEnrollmentCode,
|
||||||
@@ -524,6 +576,7 @@ import {
|
|||||||
type MobileDevice,
|
type MobileDevice,
|
||||||
type MobileEnrollmentCode,
|
type MobileEnrollmentCode,
|
||||||
type MobileSettings,
|
type MobileSettings,
|
||||||
|
type AgentUpdateSettings,
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
|
||||||
@@ -604,6 +657,7 @@ const savingPolicy = ref(false);
|
|||||||
const savingUi = ref(false);
|
const savingUi = ref(false);
|
||||||
const savingWinAdmin = ref(false);
|
const savingWinAdmin = ref(false);
|
||||||
const savingLinuxAdmin = ref(false);
|
const savingLinuxAdmin = ref(false);
|
||||||
|
const savingAgentUpdate = ref(false);
|
||||||
const savingTelegram = ref(false);
|
const savingTelegram = ref(false);
|
||||||
const savingWebhook = ref(false);
|
const savingWebhook = ref(false);
|
||||||
const savingEmail = ref(false);
|
const savingEmail = ref(false);
|
||||||
@@ -623,6 +677,7 @@ const policyLoaded = ref<NotificationPolicy | null>(null);
|
|||||||
const uiLoaded = ref<UiSettings | null>(null);
|
const uiLoaded = ref<UiSettings | null>(null);
|
||||||
const winAdminLoaded = ref<WinAdminSettings | null>(null);
|
const winAdminLoaded = ref<WinAdminSettings | null>(null);
|
||||||
const linuxAdminLoaded = ref<LinuxAdminSettings | null>(null);
|
const linuxAdminLoaded = ref<LinuxAdminSettings | null>(null);
|
||||||
|
const agentUpdateLoaded = ref<AgentUpdateSettings | null>(null);
|
||||||
const severityRows = ref<SeverityOverrideRowUi[]>([]);
|
const severityRows = ref<SeverityOverrideRowUi[]>([]);
|
||||||
const severityFilter = ref("");
|
const severityFilter = ref("");
|
||||||
const mobileLoaded = ref<MobileSettings | null>(null);
|
const mobileLoaded = ref<MobileSettings | null>(null);
|
||||||
@@ -695,6 +750,15 @@ const linuxAdminForm = reactive({
|
|||||||
password: "",
|
password: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const agentUpdateForm = reactive({
|
||||||
|
mode: "gpo" as "gpo" | "sac",
|
||||||
|
fallback_enabled: true,
|
||||||
|
fallback_after_minutes: 15,
|
||||||
|
recommended_rdp_version: "",
|
||||||
|
recommended_ssh_version: "",
|
||||||
|
win_agent_update_script: "",
|
||||||
|
});
|
||||||
|
|
||||||
const telegramForm = reactive({
|
const telegramForm = reactive({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
bot_token: "",
|
bot_token: "",
|
||||||
@@ -909,6 +973,38 @@ async function saveLinuxAdminSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAgentUpdateSettings() {
|
||||||
|
const data = await fetchAgentUpdateSettings();
|
||||||
|
agentUpdateLoaded.value = data;
|
||||||
|
agentUpdateForm.mode = data.mode;
|
||||||
|
agentUpdateForm.fallback_enabled = data.fallback_enabled;
|
||||||
|
agentUpdateForm.fallback_after_minutes = data.fallback_after_minutes;
|
||||||
|
agentUpdateForm.recommended_rdp_version = data.recommended_rdp_version ?? "";
|
||||||
|
agentUpdateForm.recommended_ssh_version = data.recommended_ssh_version ?? "";
|
||||||
|
agentUpdateForm.win_agent_update_script = data.win_agent_update_script ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAgentUpdateSettingsForm() {
|
||||||
|
savingAgentUpdate.value = true;
|
||||||
|
error.value = "";
|
||||||
|
success.value = "";
|
||||||
|
try {
|
||||||
|
agentUpdateLoaded.value = await saveAgentUpdateSettings({
|
||||||
|
mode: agentUpdateForm.mode,
|
||||||
|
fallback_enabled: agentUpdateForm.fallback_enabled,
|
||||||
|
fallback_after_minutes: agentUpdateForm.fallback_after_minutes,
|
||||||
|
recommended_rdp_version: agentUpdateForm.recommended_rdp_version.trim() || null,
|
||||||
|
recommended_ssh_version: agentUpdateForm.recommended_ssh_version.trim() || null,
|
||||||
|
win_agent_update_script: agentUpdateForm.win_agent_update_script.trim() || null,
|
||||||
|
});
|
||||||
|
success.value = "Настройки обновления агентов сохранены.";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка сохранения agent-updates";
|
||||||
|
} finally {
|
||||||
|
savingAgentUpdate.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMobileSection() {
|
async function loadMobileSection() {
|
||||||
mobileLoaded.value = await fetchMobileSettings();
|
mobileLoaded.value = await fetchMobileSettings();
|
||||||
mobileForm.devices_allowed = mobileLoaded.value.devices_allowed;
|
mobileForm.devices_allowed = mobileLoaded.value.devices_allowed;
|
||||||
@@ -1056,6 +1152,7 @@ async function load() {
|
|||||||
await loadUiSettings();
|
await loadUiSettings();
|
||||||
await loadWinAdminSettings();
|
await loadWinAdminSettings();
|
||||||
await loadLinuxAdminSettings();
|
await loadLinuxAdminSettings();
|
||||||
|
await loadAgentUpdateSettings();
|
||||||
await loadSeverityOverrides();
|
await loadSeverityOverrides();
|
||||||
await loadMobileSection();
|
await loadMobileSection();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -105,7 +105,10 @@
|
|||||||
"report.daily.ssh",
|
"report.daily.ssh",
|
||||||
"agent.heartbeat",
|
"agent.heartbeat",
|
||||||
"agent.inventory",
|
"agent.inventory",
|
||||||
"agent.test"
|
"agent.test",
|
||||||
|
"agent.update.started",
|
||||||
|
"agent.update.success",
|
||||||
|
"agent.update.failed"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"severity": {
|
"severity": {
|
||||||
|
|||||||
Reference in New Issue
Block a user