feat: git-based agent version reference and unified outdated badge (0.20.0)
SAC reads latest RDP/ssh-monitor versions from configured git repos for outdated detection, update targets, and settings test-git.
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
"""agent git release repos and version cache
|
||||||
|
|
||||||
|
Revision ID: 021
|
||||||
|
Revises: 020
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "021"
|
||||||
|
down_revision: Union[str, None] = "020"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_rdp_git_repo_url", sa.String(length=512), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_ssh_git_repo_url", sa.String(length=512), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_git_branch", sa.String(length=64), nullable=False, server_default="main"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_git_rdp_version", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_git_ssh_version", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_git_fetched_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ui_settings", "agent_git_fetched_at")
|
||||||
|
op.drop_column("ui_settings", "agent_git_ssh_version")
|
||||||
|
op.drop_column("ui_settings", "agent_git_rdp_version")
|
||||||
|
op.drop_column("ui_settings", "agent_git_branch")
|
||||||
|
op.drop_column("ui_settings", "agent_ssh_git_repo_url")
|
||||||
|
op.drop_column("ui_settings", "agent_rdp_git_repo_url")
|
||||||
@@ -10,7 +10,11 @@ from app.config import get_settings
|
|||||||
from app.database import get_db
|
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,
|
||||||
|
reference_agent_versions_by_product,
|
||||||
|
)
|
||||||
|
from app.services.agent_git_release import get_git_release_versions
|
||||||
from app.services.agent_update import (
|
from app.services.agent_update import (
|
||||||
AgentUpdateNotAllowedError,
|
AgentUpdateNotAllowedError,
|
||||||
execute_agent_update_fallback,
|
execute_agent_update_fallback,
|
||||||
@@ -92,6 +96,12 @@ def list_hosts(
|
|||||||
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)
|
latest_map = latest_agent_versions_by_product(db)
|
||||||
update_cfg = get_effective_agent_update_config(db)
|
update_cfg = get_effective_agent_update_config(db)
|
||||||
|
git_release = get_git_release_versions(update_cfg, db=db)
|
||||||
|
reference_map = reference_agent_versions_by_product(
|
||||||
|
update_cfg,
|
||||||
|
latest_map,
|
||||||
|
git_versions=git_release.versions,
|
||||||
|
)
|
||||||
|
|
||||||
items: list[HostSummary] = []
|
items: list[HostSummary] = []
|
||||||
for host in rows:
|
for host in rows:
|
||||||
@@ -117,7 +127,12 @@ 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),
|
version_status=host_version_status(
|
||||||
|
host,
|
||||||
|
cfg=update_cfg,
|
||||||
|
latest_map=latest_map,
|
||||||
|
git_versions=git_release.versions,
|
||||||
|
),
|
||||||
pending_agent_update=bool(host.pending_agent_update),
|
pending_agent_update=bool(host.pending_agent_update),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -127,7 +142,8 @@ def list_hosts(
|
|||||||
total=total,
|
total=total,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
latest_agent_versions=latest_agent_versions_by_product(db),
|
latest_agent_versions=latest_map,
|
||||||
|
reference_agent_versions=reference_map,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -145,6 +161,7 @@ def _host_detail_from_model(
|
|||||||
last_hb = hb_map.get(host.id)
|
last_hb = hb_map.get(host.id)
|
||||||
latest_map = latest_agent_versions_by_product(db)
|
latest_map = latest_agent_versions_by_product(db)
|
||||||
update_cfg = get_effective_agent_update_config(db)
|
update_cfg = get_effective_agent_update_config(db)
|
||||||
|
git_release = get_git_release_versions(update_cfg, db=db)
|
||||||
return HostDetail(
|
return HostDetail(
|
||||||
id=host.id,
|
id=host.id,
|
||||||
hostname=host.hostname,
|
hostname=host.hostname,
|
||||||
@@ -161,7 +178,12 @@ 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),
|
version_status=host_version_status(
|
||||||
|
host,
|
||||||
|
cfg=update_cfg,
|
||||||
|
latest_map=latest_map,
|
||||||
|
git_versions=git_release.versions,
|
||||||
|
),
|
||||||
pending_agent_update=bool(host.pending_agent_update),
|
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 [],
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -45,6 +47,7 @@ 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_git_release import get_git_release_versions
|
||||||
from app.services.agent_update_settings import (
|
from app.services.agent_update_settings import (
|
||||||
get_effective_agent_update_config,
|
get_effective_agent_update_config,
|
||||||
upsert_agent_update_settings,
|
upsert_agent_update_settings,
|
||||||
@@ -555,6 +558,13 @@ class AgentUpdateSettingsResponse(BaseModel):
|
|||||||
min_rdp_version: str | None = None
|
min_rdp_version: str | None = None
|
||||||
min_ssh_version: str | None = None
|
min_ssh_version: str | None = None
|
||||||
win_agent_update_script: str | None = None
|
win_agent_update_script: str | None = None
|
||||||
|
rdp_git_repo_url: str | None = None
|
||||||
|
ssh_git_repo_url: str | None = None
|
||||||
|
git_branch: str = "main"
|
||||||
|
git_latest_rdp_version: str | None = None
|
||||||
|
git_latest_ssh_version: str | None = None
|
||||||
|
git_fetched_at: datetime | None = None
|
||||||
|
git_errors: dict[str, str] = Field(default_factory=dict)
|
||||||
source: str = Field(description="env или db")
|
source: str = Field(description="env или db")
|
||||||
|
|
||||||
|
|
||||||
@@ -567,9 +577,31 @@ class AgentUpdateSettingsUpdate(BaseModel):
|
|||||||
min_rdp_version: str | None = None
|
min_rdp_version: str | None = None
|
||||||
min_ssh_version: str | None = None
|
min_ssh_version: str | None = None
|
||||||
win_agent_update_script: str | None = None
|
win_agent_update_script: str | None = None
|
||||||
|
rdp_git_repo_url: str | None = None
|
||||||
|
ssh_git_repo_url: str | None = None
|
||||||
|
git_branch: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _agent_update_to_response(cfg) -> AgentUpdateSettingsResponse:
|
class AgentGitTestResponse(BaseModel):
|
||||||
|
git_latest_rdp_version: str | None = None
|
||||||
|
git_latest_ssh_version: str | None = None
|
||||||
|
git_fetched_at: datetime | None = None
|
||||||
|
git_errors: dict[str, str] = Field(default_factory=dict)
|
||||||
|
from_cache: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_update_to_response(cfg, git_release=None) -> AgentUpdateSettingsResponse:
|
||||||
|
git_latest_rdp = None
|
||||||
|
git_latest_ssh = None
|
||||||
|
git_fetched_at = None
|
||||||
|
git_errors: dict[str, str] = {}
|
||||||
|
if git_release is not None:
|
||||||
|
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||||
|
|
||||||
|
git_latest_rdp = git_release.versions.get(PRODUCT_RDP) or None
|
||||||
|
git_latest_ssh = git_release.versions.get(PRODUCT_SSH) or None
|
||||||
|
git_fetched_at = git_release.fetched_at
|
||||||
|
git_errors = dict(git_release.errors)
|
||||||
return AgentUpdateSettingsResponse(
|
return AgentUpdateSettingsResponse(
|
||||||
mode=cfg.mode,
|
mode=cfg.mode,
|
||||||
fallback_enabled=cfg.fallback_enabled,
|
fallback_enabled=cfg.fallback_enabled,
|
||||||
@@ -579,6 +611,13 @@ def _agent_update_to_response(cfg) -> AgentUpdateSettingsResponse:
|
|||||||
min_rdp_version=cfg.min_rdp_version or None,
|
min_rdp_version=cfg.min_rdp_version or None,
|
||||||
min_ssh_version=cfg.min_ssh_version or None,
|
min_ssh_version=cfg.min_ssh_version or None,
|
||||||
win_agent_update_script=cfg.win_agent_update_script or None,
|
win_agent_update_script=cfg.win_agent_update_script or None,
|
||||||
|
rdp_git_repo_url=cfg.rdp_git_repo_url or None,
|
||||||
|
ssh_git_repo_url=cfg.ssh_git_repo_url or None,
|
||||||
|
git_branch=cfg.git_branch or "main",
|
||||||
|
git_latest_rdp_version=git_latest_rdp,
|
||||||
|
git_latest_ssh_version=git_latest_ssh,
|
||||||
|
git_fetched_at=git_fetched_at,
|
||||||
|
git_errors=git_errors,
|
||||||
source=cfg.source,
|
source=cfg.source,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -588,7 +627,9 @@ def get_agent_update_settings(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user=Depends(require_admin),
|
_user=Depends(require_admin),
|
||||||
) -> AgentUpdateSettingsResponse:
|
) -> AgentUpdateSettingsResponse:
|
||||||
return _agent_update_to_response(get_effective_agent_update_config(db))
|
cfg = get_effective_agent_update_config(db)
|
||||||
|
git_release = get_git_release_versions(cfg, db=db)
|
||||||
|
return _agent_update_to_response(cfg, git_release)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/agent-updates", response_model=AgentUpdateSettingsResponse)
|
@router.put("/agent-updates", response_model=AgentUpdateSettingsResponse)
|
||||||
@@ -608,7 +649,29 @@ def update_agent_update_settings(
|
|||||||
min_rdp_version=body.min_rdp_version,
|
min_rdp_version=body.min_rdp_version,
|
||||||
min_ssh_version=body.min_ssh_version,
|
min_ssh_version=body.min_ssh_version,
|
||||||
win_agent_update_script=body.win_agent_update_script,
|
win_agent_update_script=body.win_agent_update_script,
|
||||||
|
rdp_git_repo_url=body.rdp_git_repo_url,
|
||||||
|
ssh_git_repo_url=body.ssh_git_repo_url,
|
||||||
|
git_branch=body.git_branch,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
return _agent_update_to_response(cfg)
|
git_release = get_git_release_versions(cfg, db=db, force_refresh=True)
|
||||||
|
return _agent_update_to_response(cfg, git_release)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agent-updates/test-git", response_model=AgentGitTestResponse)
|
||||||
|
def test_agent_git_release(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> AgentGitTestResponse:
|
||||||
|
cfg = get_effective_agent_update_config(db)
|
||||||
|
git_release = get_git_release_versions(cfg, db=db, force_refresh=True)
|
||||||
|
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||||
|
|
||||||
|
return AgentGitTestResponse(
|
||||||
|
git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None,
|
||||||
|
git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None,
|
||||||
|
git_fetched_at=git_release.fetched_at,
|
||||||
|
git_errors=dict(git_release.errors),
|
||||||
|
from_cache=git_release.from_cache,
|
||||||
|
)
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ class Settings(BaseSettings):
|
|||||||
sac_agent_min_rdp_version: str = ""
|
sac_agent_min_rdp_version: str = ""
|
||||||
sac_agent_min_ssh_version: str = ""
|
sac_agent_min_ssh_version: str = ""
|
||||||
sac_win_agent_update_script: str = ""
|
sac_win_agent_update_script: str = ""
|
||||||
|
sac_agent_rdp_git_repo_url: str = "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
|
||||||
|
sac_agent_ssh_git_repo_url: str = "https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
|
||||||
|
sac_agent_git_branch: str = "main"
|
||||||
|
sac_agent_git_cache_dir: str = "/opt/security-alert-center/cache/agent-repos"
|
||||||
|
sac_agent_git_cache_ttl_minutes: int = 30
|
||||||
|
sac_agent_git_token: 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
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from sqlalchemy import Boolean, String, Text
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -25,3 +27,9 @@ class UiSettings(Base):
|
|||||||
agent_min_rdp_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)
|
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)
|
win_agent_update_script: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
agent_rdp_git_repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
agent_ssh_git_repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
agent_git_branch: Mapped[str] = mapped_column(String(64), default="main", nullable=False)
|
||||||
|
agent_git_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
agent_git_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
agent_git_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ class HostListResponse(BaseModel):
|
|||||||
page: int
|
page: int
|
||||||
page_size: int
|
page_size: int
|
||||||
latest_agent_versions: dict[str, str] = Field(default_factory=dict)
|
latest_agent_versions: dict[str, str] = Field(default_factory=dict)
|
||||||
|
reference_agent_versions: dict[str, str] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Эталон для «устарела»: max(git latest, min, max в fleet)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EventSummary(BaseModel):
|
class EventSummary(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""Fetch latest agent release versions from git repositories."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||||
|
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH, AgentUpdateConfig
|
||||||
|
from app.services.agent_version import parse_agent_version
|
||||||
|
|
||||||
|
_SCRIPT_VERSION_RE = re.compile(r'\$ScriptVersion\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE)
|
||||||
|
_SSH_VERSION_RE = re.compile(r'^SSH_MONITOR_VERSION=["\']([^"\']+)["\']', re.MULTILINE)
|
||||||
|
|
||||||
|
_MEMORY_CACHE: dict[str, tuple[dict[str, str], float, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GitReleaseVersions:
|
||||||
|
versions: dict[str, str]
|
||||||
|
fetched_at: datetime | None
|
||||||
|
from_cache: bool
|
||||||
|
errors: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_git_repo_url(raw: str) -> str:
|
||||||
|
text = (raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if not text.startswith(("http://", "https://", "git@")):
|
||||||
|
text = f"https://{text.lstrip('/')}"
|
||||||
|
if text.startswith("git@"):
|
||||||
|
return text
|
||||||
|
if not text.endswith(".git"):
|
||||||
|
text = f"{text.rstrip('/')}.git"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def git_repo_url_with_auth(repo_url: str, token: str) -> str:
|
||||||
|
normalized = normalize_git_repo_url(repo_url)
|
||||||
|
if not token or not normalized.startswith("https://"):
|
||||||
|
return normalized
|
||||||
|
parsed = urlparse(normalized)
|
||||||
|
host = parsed.netloc
|
||||||
|
path = parsed.path or ""
|
||||||
|
return f"https://oauth2:{token}@{host}{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(cfg: AgentUpdateConfig) -> str:
|
||||||
|
parts = [
|
||||||
|
cfg.rdp_git_repo_url,
|
||||||
|
cfg.ssh_git_repo_url,
|
||||||
|
cfg.git_branch,
|
||||||
|
]
|
||||||
|
return hashlib.sha256("|".join(parts).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_text(path: Path) -> str | None:
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
return path.read_text(encoding="utf-8-sig")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rdp_version_from_files(version_txt: str | None, login_monitor_ps1: str | None) -> str | None:
|
||||||
|
if version_txt:
|
||||||
|
first_line = version_txt.strip().splitlines()[0].strip() if version_txt.strip() else ""
|
||||||
|
if parse_agent_version(first_line):
|
||||||
|
return first_line
|
||||||
|
if login_monitor_ps1:
|
||||||
|
match = _SCRIPT_VERSION_RE.search(login_monitor_ps1)
|
||||||
|
if match:
|
||||||
|
candidate = match.group(1).strip()
|
||||||
|
if parse_agent_version(candidate):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ssh_version_from_files(ssh_monitor: str | None, version_txt: str | None) -> str | None:
|
||||||
|
if ssh_monitor:
|
||||||
|
match = _SSH_VERSION_RE.search(ssh_monitor)
|
||||||
|
if match:
|
||||||
|
candidate = match.group(1).strip()
|
||||||
|
if parse_agent_version(candidate):
|
||||||
|
return candidate
|
||||||
|
if version_txt:
|
||||||
|
first_line = version_txt.strip().splitlines()[0].strip() if version_txt.strip() else ""
|
||||||
|
if parse_agent_version(first_line):
|
||||||
|
return first_line
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_product_version_from_dir(repo_dir: Path, product: str) -> str | None:
|
||||||
|
if product == PRODUCT_RDP:
|
||||||
|
return parse_rdp_version_from_files(
|
||||||
|
_read_text(repo_dir / "version.txt"),
|
||||||
|
_read_text(repo_dir / "Login_Monitor.ps1"),
|
||||||
|
)
|
||||||
|
if product == PRODUCT_SSH:
|
||||||
|
return parse_ssh_version_from_files(
|
||||||
|
_read_text(repo_dir / "ssh-monitor"),
|
||||||
|
_read_text(repo_dir / "version.txt"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(args: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=cwd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_slug(repo_url: str) -> str:
|
||||||
|
normalized = normalize_git_repo_url(repo_url)
|
||||||
|
name = normalized.rstrip("/").split("/")[-1]
|
||||||
|
if name.endswith(".git"):
|
||||||
|
name = name[:-4]
|
||||||
|
digest = hashlib.sha256(normalized.encode()).hexdigest()[:12]
|
||||||
|
return f"{name}-{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def clone_or_update_repo(repo_url: str, branch: str, cache_dir: Path, token: str = "") -> Path:
|
||||||
|
clone_url = git_repo_url_with_auth(repo_url, token)
|
||||||
|
normalized = normalize_git_repo_url(repo_url)
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("empty repo url")
|
||||||
|
|
||||||
|
branch_name = (branch or "main").strip() or "main"
|
||||||
|
target = cache_dir / _repo_slug(normalized)
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if target.is_dir() and (target / ".git").is_dir():
|
||||||
|
fetch = _run_git(["fetch", "--depth", "1", "origin", branch_name], cwd=target)
|
||||||
|
if fetch.returncode != 0:
|
||||||
|
shutil.rmtree(target, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
checkout = _run_git(["checkout", "FETCH_HEAD"], cwd=target)
|
||||||
|
if checkout.returncode == 0:
|
||||||
|
return target
|
||||||
|
shutil.rmtree(target, ignore_errors=True)
|
||||||
|
|
||||||
|
clone = _run_git(
|
||||||
|
["clone", "--depth", "1", "--branch", branch_name, clone_url, str(target)],
|
||||||
|
)
|
||||||
|
if clone.returncode != 0:
|
||||||
|
err = (clone.stderr or clone.stdout or "git clone failed").strip()
|
||||||
|
raise RuntimeError(err)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_product_version(repo_url: str, branch: str, product: str, cache_dir: Path, token: str = "") -> str:
|
||||||
|
repo_dir = clone_or_update_repo(repo_url, branch, cache_dir, token=token)
|
||||||
|
version = parse_product_version_from_dir(repo_dir, product)
|
||||||
|
if not version:
|
||||||
|
raise RuntimeError(f"version not found in {repo_url} ({product})")
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def recommended_from_git(cfg: AgentUpdateConfig, git_versions: dict[str, str], product: str) -> str:
|
||||||
|
git_version = (git_versions.get(product) or "").strip()
|
||||||
|
if git_version:
|
||||||
|
return git_version
|
||||||
|
manual = cfg.recommended_for_product(product)
|
||||||
|
return manual
|
||||||
|
|
||||||
|
|
||||||
|
def _ttl_seconds() -> int:
|
||||||
|
settings = get_settings()
|
||||||
|
return max(60, int(settings.sac_agent_git_cache_ttl_minutes) * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_dir() -> Path:
|
||||||
|
settings = get_settings()
|
||||||
|
return Path(settings.sac_agent_git_cache_dir or "/tmp/sac-agent-repos")
|
||||||
|
|
||||||
|
|
||||||
|
def _db_cache_fresh(row: UiSettings | None, cfg: AgentUpdateConfig) -> bool:
|
||||||
|
if row is None or row.agent_git_fetched_at is None:
|
||||||
|
return False
|
||||||
|
fetched_at = row.agent_git_fetched_at
|
||||||
|
if fetched_at.tzinfo is None:
|
||||||
|
fetched_at = fetched_at.replace(tzinfo=timezone.utc)
|
||||||
|
age = (datetime.now(timezone.utc) - fetched_at).total_seconds()
|
||||||
|
if age > _ttl_seconds():
|
||||||
|
return False
|
||||||
|
if (row.agent_rdp_git_repo_url or "").strip() != cfg.rdp_git_repo_url:
|
||||||
|
return False
|
||||||
|
if (row.agent_ssh_git_repo_url or "").strip() != cfg.ssh_git_repo_url:
|
||||||
|
return False
|
||||||
|
if (row.agent_git_branch or "main").strip() != cfg.git_branch:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _versions_from_db_row(row: UiSettings) -> dict[str, str]:
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
rdp = (row.agent_git_rdp_version or "").strip()
|
||||||
|
ssh = (row.agent_git_ssh_version or "").strip()
|
||||||
|
if rdp:
|
||||||
|
out[PRODUCT_RDP] = rdp
|
||||||
|
if ssh:
|
||||||
|
out[PRODUCT_SSH] = ssh
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_db_cache(db: Session, cfg: AgentUpdateConfig, versions: dict[str, str]) -> datetime:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
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)
|
||||||
|
row.agent_rdp_git_repo_url = cfg.rdp_git_repo_url or None
|
||||||
|
row.agent_ssh_git_repo_url = cfg.ssh_git_repo_url or None
|
||||||
|
row.agent_git_branch = cfg.git_branch or "main"
|
||||||
|
row.agent_git_rdp_version = versions.get(PRODUCT_RDP) or None
|
||||||
|
row.agent_git_ssh_version = versions.get(PRODUCT_SSH) or None
|
||||||
|
row.agent_git_fetched_at = now
|
||||||
|
db.commit()
|
||||||
|
return now
|
||||||
|
|
||||||
|
|
||||||
|
def get_git_release_versions(
|
||||||
|
cfg: AgentUpdateConfig,
|
||||||
|
*,
|
||||||
|
db: Session | None = None,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
) -> GitReleaseVersions:
|
||||||
|
key = _cache_key(cfg)
|
||||||
|
now_ts = time.time()
|
||||||
|
ttl = _ttl_seconds()
|
||||||
|
|
||||||
|
if not force_refresh:
|
||||||
|
cached = _MEMORY_CACHE.get(key)
|
||||||
|
if cached and now_ts - cached[1] <= ttl:
|
||||||
|
fetched_at = datetime.fromtimestamp(cached[1], tz=timezone.utc)
|
||||||
|
return GitReleaseVersions(versions=dict(cached[0]), fetched_at=fetched_at, from_cache=True)
|
||||||
|
|
||||||
|
if db is not None:
|
||||||
|
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||||
|
if _db_cache_fresh(row, cfg):
|
||||||
|
versions = _versions_from_db_row(row)
|
||||||
|
if versions:
|
||||||
|
_MEMORY_CACHE[key] = (versions, row.agent_git_fetched_at.timestamp(), key)
|
||||||
|
return GitReleaseVersions(
|
||||||
|
versions=versions,
|
||||||
|
fetched_at=row.agent_git_fetched_at,
|
||||||
|
from_cache=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
cache_dir = _cache_dir()
|
||||||
|
token = (settings.sac_agent_git_token or "").strip()
|
||||||
|
branch = cfg.git_branch or "main"
|
||||||
|
versions: dict[str, str] = {}
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
product_repo = {
|
||||||
|
PRODUCT_RDP: cfg.rdp_git_repo_url,
|
||||||
|
PRODUCT_SSH: cfg.ssh_git_repo_url,
|
||||||
|
}
|
||||||
|
for product, repo_url in product_repo.items():
|
||||||
|
if not repo_url:
|
||||||
|
errors[product] = "repo url not configured"
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
versions[product] = fetch_product_version(repo_url, branch, product, cache_dir, token=token)
|
||||||
|
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc:
|
||||||
|
errors[product] = str(exc).strip() or "fetch failed"
|
||||||
|
|
||||||
|
fetched_at: datetime | None = None
|
||||||
|
if versions:
|
||||||
|
fetched_at = datetime.now(timezone.utc)
|
||||||
|
_MEMORY_CACHE[key] = (dict(versions), fetched_at.timestamp(), key)
|
||||||
|
if db is not None:
|
||||||
|
fetched_at = _persist_db_cache(db, cfg, versions)
|
||||||
|
|
||||||
|
if not versions and db is not None:
|
||||||
|
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||||
|
if row is not None:
|
||||||
|
stale = _versions_from_db_row(row)
|
||||||
|
if stale:
|
||||||
|
return GitReleaseVersions(
|
||||||
|
versions=stale,
|
||||||
|
fetched_at=row.agent_git_fetched_at,
|
||||||
|
from_cache=True,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
return GitReleaseVersions(
|
||||||
|
versions=versions,
|
||||||
|
fetched_at=fetched_at,
|
||||||
|
from_cache=False,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
@@ -14,10 +14,10 @@ from app.services.agent_update_settings import (
|
|||||||
AgentUpdateConfig,
|
AgentUpdateConfig,
|
||||||
get_effective_agent_update_config,
|
get_effective_agent_update_config,
|
||||||
)
|
)
|
||||||
|
from app.services.agent_git_release import get_git_release_versions, recommended_from_git
|
||||||
from app.services.agent_version import (
|
from app.services.agent_version import (
|
||||||
is_agent_version_outdated,
|
host_version_outdated,
|
||||||
latest_agent_versions_by_product,
|
latest_agent_versions_by_product,
|
||||||
parse_agent_version,
|
|
||||||
)
|
)
|
||||||
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.ssh_connect import (
|
from app.services.ssh_connect import (
|
||||||
@@ -49,12 +49,21 @@ class AgentUpdateNotAllowedError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def resolve_target_version(db: Session, host: Host, cfg: AgentUpdateConfig) -> str:
|
def resolve_target_version(
|
||||||
recommended = cfg.recommended_for_product(host.product or "")
|
db: Session,
|
||||||
if recommended:
|
host: Host,
|
||||||
return recommended
|
cfg: AgentUpdateConfig,
|
||||||
|
*,
|
||||||
|
git_versions: dict[str, str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
product = host.product or ""
|
||||||
|
if git_versions is None:
|
||||||
|
git_versions = get_git_release_versions(cfg, db=db).versions
|
||||||
|
git_target = recommended_from_git(cfg, git_versions, product)
|
||||||
|
if git_target:
|
||||||
|
return git_target
|
||||||
latest_map = latest_agent_versions_by_product(db)
|
latest_map = latest_agent_versions_by_product(db)
|
||||||
return latest_map.get(host.product or "", "") or (host.product_version or "")
|
return latest_map.get(product, "") or (host.product_version or "")
|
||||||
|
|
||||||
|
|
||||||
def host_version_status(
|
def host_version_status(
|
||||||
@@ -62,17 +71,20 @@ def host_version_status(
|
|||||||
*,
|
*,
|
||||||
cfg: AgentUpdateConfig,
|
cfg: AgentUpdateConfig,
|
||||||
latest_map: dict[str, str],
|
latest_map: dict[str, str],
|
||||||
|
git_versions: dict[str, str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
current = host.product_version
|
product = host.product or ""
|
||||||
if not current or parse_agent_version(current) is None:
|
if git_versions is None:
|
||||||
|
git_versions = get_git_release_versions(cfg).versions
|
||||||
|
outdated = host_version_outdated(
|
||||||
|
host.product_version,
|
||||||
|
recommended=recommended_from_git(cfg, git_versions, product),
|
||||||
|
fleet_latest=latest_map.get(product, ""),
|
||||||
|
min_version=cfg.min_for_product(product),
|
||||||
|
)
|
||||||
|
if outdated is None:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
recommended = cfg.recommended_for_product(host.product or "")
|
return "outdated" if outdated else "ok"
|
||||||
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:
|
def request_agent_update(db: Session, host: Host) -> Host:
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class AgentUpdateConfig:
|
|||||||
min_rdp_version: str
|
min_rdp_version: str
|
||||||
min_ssh_version: str
|
min_ssh_version: str
|
||||||
win_agent_update_script: str
|
win_agent_update_script: str
|
||||||
|
rdp_git_repo_url: str
|
||||||
|
ssh_git_repo_url: str
|
||||||
|
git_branch: str
|
||||||
source: str # env | db
|
source: str # env | db
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -59,6 +62,9 @@ def _agent_update_from_env() -> AgentUpdateConfig:
|
|||||||
min_rdp_version=(settings.sac_agent_min_rdp_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(),
|
min_ssh_version=(settings.sac_agent_min_ssh_version or "").strip(),
|
||||||
win_agent_update_script=(settings.sac_win_agent_update_script or "").strip(),
|
win_agent_update_script=(settings.sac_win_agent_update_script or "").strip(),
|
||||||
|
rdp_git_repo_url=(settings.sac_agent_rdp_git_repo_url or "").strip(),
|
||||||
|
ssh_git_repo_url=(settings.sac_agent_ssh_git_repo_url or "").strip(),
|
||||||
|
git_branch=(settings.sac_agent_git_branch or "main").strip() or "main",
|
||||||
source="env",
|
source="env",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,6 +80,9 @@ def _row_has_agent_update_values(row: UiSettings) -> bool:
|
|||||||
bool((row.agent_min_rdp_version or "").strip()),
|
bool((row.agent_min_rdp_version or "").strip()),
|
||||||
bool((row.agent_min_ssh_version or "").strip()),
|
bool((row.agent_min_ssh_version or "").strip()),
|
||||||
bool((row.win_agent_update_script or "").strip()),
|
bool((row.win_agent_update_script or "").strip()),
|
||||||
|
bool((row.agent_rdp_git_repo_url or "").strip()),
|
||||||
|
bool((row.agent_ssh_git_repo_url or "").strip()),
|
||||||
|
(row.agent_git_branch or "main").strip() not in ("", "main"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,6 +125,9 @@ def get_effective_agent_update_config(db: Session | None = None) -> AgentUpdateC
|
|||||||
min_ssh_version=(row.agent_min_ssh_version or "").strip() or env_cfg.min_ssh_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()
|
win_agent_update_script=(row.win_agent_update_script or "").strip()
|
||||||
or env_cfg.win_agent_update_script,
|
or env_cfg.win_agent_update_script,
|
||||||
|
rdp_git_repo_url=(row.agent_rdp_git_repo_url or "").strip() or env_cfg.rdp_git_repo_url,
|
||||||
|
ssh_git_repo_url=(row.agent_ssh_git_repo_url or "").strip() or env_cfg.ssh_git_repo_url,
|
||||||
|
git_branch=(row.agent_git_branch or env_cfg.git_branch).strip() or env_cfg.git_branch,
|
||||||
source="db" if _row_has_agent_update_values(row) else env_cfg.source,
|
source="db" if _row_has_agent_update_values(row) else env_cfg.source,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -131,6 +143,9 @@ def upsert_agent_update_settings(
|
|||||||
min_rdp_version: str | None = None,
|
min_rdp_version: str | None = None,
|
||||||
min_ssh_version: str | None = None,
|
min_ssh_version: str | None = None,
|
||||||
win_agent_update_script: str | None = None,
|
win_agent_update_script: str | None = None,
|
||||||
|
rdp_git_repo_url: str | None = None,
|
||||||
|
ssh_git_repo_url: str | None = None,
|
||||||
|
git_branch: str | None = None,
|
||||||
) -> AgentUpdateConfig:
|
) -> AgentUpdateConfig:
|
||||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||||
if row is None:
|
if row is None:
|
||||||
@@ -156,6 +171,21 @@ def upsert_agent_update_settings(
|
|||||||
row.agent_min_ssh_version = min_ssh_version.strip() or None
|
row.agent_min_ssh_version = min_ssh_version.strip() or None
|
||||||
if win_agent_update_script is not None:
|
if win_agent_update_script is not None:
|
||||||
row.win_agent_update_script = win_agent_update_script.strip() or None
|
row.win_agent_update_script = win_agent_update_script.strip() or None
|
||||||
|
if rdp_git_repo_url is not None:
|
||||||
|
row.agent_rdp_git_repo_url = rdp_git_repo_url.strip() or None
|
||||||
|
row.agent_git_rdp_version = None
|
||||||
|
row.agent_git_fetched_at = None
|
||||||
|
if ssh_git_repo_url is not None:
|
||||||
|
row.agent_ssh_git_repo_url = ssh_git_repo_url.strip() or None
|
||||||
|
row.agent_git_ssh_version = None
|
||||||
|
row.agent_git_fetched_at = None
|
||||||
|
if git_branch is not None:
|
||||||
|
branch = git_branch.strip() or "main"
|
||||||
|
if branch != (row.agent_git_branch or "main"):
|
||||||
|
row.agent_git_rdp_version = None
|
||||||
|
row.agent_git_ssh_version = None
|
||||||
|
row.agent_git_fetched_at = None
|
||||||
|
row.agent_git_branch = branch
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
|
|||||||
@@ -60,6 +60,75 @@ def is_agent_version_outdated(
|
|||||||
return lag >= lag_threshold
|
return lag >= lag_threshold
|
||||||
|
|
||||||
|
|
||||||
|
def effective_reference_version(
|
||||||
|
*,
|
||||||
|
recommended: str = "",
|
||||||
|
fleet_latest: str = "",
|
||||||
|
min_version: str = "",
|
||||||
|
) -> str | None:
|
||||||
|
"""Единый эталон: max(recommended, min_version, max версия в fleet по продукту)."""
|
||||||
|
best: ParsedAgentVersion | None = None
|
||||||
|
best_raw: str | None = None
|
||||||
|
for raw in (recommended, min_version, fleet_latest):
|
||||||
|
text = (raw or "").strip()
|
||||||
|
parsed = parse_agent_version(text)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
if best is None or parsed > best:
|
||||||
|
best = parsed
|
||||||
|
best_raw = text
|
||||||
|
return best_raw
|
||||||
|
|
||||||
|
|
||||||
|
def reference_agent_versions_by_product(
|
||||||
|
cfg,
|
||||||
|
latest_map: dict[str, str],
|
||||||
|
*,
|
||||||
|
git_versions: dict[str, str] | None = None,
|
||||||
|
products: tuple[str, ...] = ("rdp-login-monitor", "ssh-monitor"),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
from app.services.agent_git_release import recommended_from_git
|
||||||
|
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||||
|
|
||||||
|
git_versions = git_versions or {}
|
||||||
|
product_cfg = {
|
||||||
|
PRODUCT_RDP: (recommended_from_git(cfg, git_versions, PRODUCT_RDP), cfg.min_for_product(PRODUCT_RDP)),
|
||||||
|
PRODUCT_SSH: (recommended_from_git(cfg, git_versions, PRODUCT_SSH), cfg.min_for_product(PRODUCT_SSH)),
|
||||||
|
}
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for product in products:
|
||||||
|
recommended, min_version = product_cfg.get(product, ("", ""))
|
||||||
|
ref = effective_reference_version(
|
||||||
|
recommended=recommended,
|
||||||
|
fleet_latest=latest_map.get(product, ""),
|
||||||
|
min_version=min_version,
|
||||||
|
)
|
||||||
|
if ref:
|
||||||
|
out[product] = ref
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def host_version_outdated(
|
||||||
|
host_version: str | None,
|
||||||
|
*,
|
||||||
|
recommended: str = "",
|
||||||
|
fleet_latest: str = "",
|
||||||
|
min_version: str = "",
|
||||||
|
lag_threshold: int = _DEFAULT_LAG_THRESHOLD,
|
||||||
|
) -> bool | None:
|
||||||
|
"""True=outdated, False=ok, None=unknown (нет эталона или непарсится версия хоста)."""
|
||||||
|
if not host_version or parse_agent_version(host_version) is None:
|
||||||
|
return None
|
||||||
|
reference = effective_reference_version(
|
||||||
|
recommended=recommended,
|
||||||
|
fleet_latest=fleet_latest,
|
||||||
|
min_version=min_version,
|
||||||
|
)
|
||||||
|
if not reference:
|
||||||
|
return None
|
||||||
|
return is_agent_version_outdated(host_version, reference, lag_threshold=lag_threshold)
|
||||||
|
|
||||||
|
|
||||||
def latest_agent_versions_by_product(db: Session) -> dict[str, str]:
|
def latest_agent_versions_by_product(db: Session) -> dict[str, str]:
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(Host.product, Host.product_version).where(
|
select(Host.product, Host.product_version).where(
|
||||||
|
|||||||
@@ -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.12.1"
|
APP_VERSION = "0.20.0"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -80,6 +80,30 @@ def db_session(db_engine):
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_agent_git_release(monkeypatch):
|
||||||
|
"""API tests must not clone real git repos."""
|
||||||
|
from app.services.agent_git_release import GitReleaseVersions
|
||||||
|
|
||||||
|
empty = GitReleaseVersions(versions={}, fetched_at=None, from_cache=True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.agent_git_release.get_git_release_versions",
|
||||||
|
lambda cfg, **kwargs: empty,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.v1.hosts.get_git_release_versions",
|
||||||
|
lambda cfg, **kwargs: empty,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.v1.settings.get_git_release_versions",
|
||||||
|
lambda cfg, **kwargs: empty,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.agent_update.get_git_release_versions",
|
||||||
|
lambda cfg, **kwargs: empty,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(db_session, monkeypatch):
|
def client(db_session, monkeypatch):
|
||||||
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
|
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Tests for git-based agent release version resolution."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.services.agent_git_release import (
|
||||||
|
normalize_git_repo_url,
|
||||||
|
parse_product_version_from_dir,
|
||||||
|
parse_rdp_version_from_files,
|
||||||
|
parse_ssh_version_from_files,
|
||||||
|
recommended_from_git,
|
||||||
|
)
|
||||||
|
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH, AgentUpdateConfig
|
||||||
|
from app.services.agent_version import reference_agent_versions_by_product
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_git_repo_url():
|
||||||
|
assert normalize_git_repo_url("git.kalinamall.ru/PapaTramp/ssh-monitor").endswith(
|
||||||
|
"ssh-monitor.git"
|
||||||
|
)
|
||||||
|
assert normalize_git_repo_url("https://example.com/repo.git") == "https://example.com/repo.git"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_rdp_version_from_files():
|
||||||
|
assert parse_rdp_version_from_files("2.1.2-SAC\n", None) == "2.1.2-SAC"
|
||||||
|
assert (
|
||||||
|
parse_rdp_version_from_files(None, '$ScriptVersion = "2.0.35-SAC"')
|
||||||
|
== "2.0.35-SAC"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_ssh_version_from_files():
|
||||||
|
assert parse_ssh_version_from_files('SSH_MONITOR_VERSION="2.1.0-SAC"\n', None) == "2.1.0-SAC"
|
||||||
|
assert parse_ssh_version_from_files(None, "2.0.6-SAC") == "2.0.6-SAC"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_product_version_from_dir(tmp_path: Path):
|
||||||
|
(tmp_path / "version.txt").write_text("2.1.2-SAC\n", encoding="utf-8")
|
||||||
|
assert parse_product_version_from_dir(tmp_path, PRODUCT_RDP) == "2.1.2-SAC"
|
||||||
|
|
||||||
|
ssh_dir = tmp_path / "ssh"
|
||||||
|
ssh_dir.mkdir()
|
||||||
|
(ssh_dir / "ssh-monitor").write_text('SSH_MONITOR_VERSION="2.1.0-SAC"\n', encoding="utf-8")
|
||||||
|
assert parse_product_version_from_dir(ssh_dir, PRODUCT_SSH) == "2.1.0-SAC"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommended_from_git_prefers_git():
|
||||||
|
cfg = AgentUpdateConfig(
|
||||||
|
mode="sac",
|
||||||
|
fallback_enabled=True,
|
||||||
|
fallback_after_minutes=15,
|
||||||
|
recommended_rdp_version="2.0.0-SAC",
|
||||||
|
recommended_ssh_version="",
|
||||||
|
min_rdp_version="",
|
||||||
|
min_ssh_version="",
|
||||||
|
win_agent_update_script="",
|
||||||
|
rdp_git_repo_url="https://git.example/rdp.git",
|
||||||
|
ssh_git_repo_url="https://git.example/ssh.git",
|
||||||
|
git_branch="main",
|
||||||
|
source="env",
|
||||||
|
)
|
||||||
|
git_versions = {PRODUCT_SSH: "2.1.0-SAC"}
|
||||||
|
assert recommended_from_git(cfg, git_versions, PRODUCT_SSH) == "2.1.0-SAC"
|
||||||
|
assert recommended_from_git(cfg, git_versions, PRODUCT_RDP) == "2.0.0-SAC"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_agent_versions_uses_git():
|
||||||
|
cfg = AgentUpdateConfig(
|
||||||
|
mode="gpo",
|
||||||
|
fallback_enabled=True,
|
||||||
|
fallback_after_minutes=15,
|
||||||
|
recommended_rdp_version="",
|
||||||
|
recommended_ssh_version="",
|
||||||
|
min_rdp_version="",
|
||||||
|
min_ssh_version="",
|
||||||
|
win_agent_update_script="",
|
||||||
|
rdp_git_repo_url="",
|
||||||
|
ssh_git_repo_url="",
|
||||||
|
git_branch="main",
|
||||||
|
source="env",
|
||||||
|
)
|
||||||
|
refs = reference_agent_versions_by_product(
|
||||||
|
cfg,
|
||||||
|
{"ssh-monitor": "2.0.6-SAC"},
|
||||||
|
git_versions={"ssh-monitor": "2.1.0-SAC"},
|
||||||
|
)
|
||||||
|
assert refs["ssh-monitor"] == "2.1.0-SAC"
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
"""Agent version parse/compare for outdated highlighting."""
|
"""Agent version parse/compare for outdated highlighting."""
|
||||||
|
|
||||||
from app.services.agent_version import is_agent_version_outdated, latest_agent_versions_by_product, parse_agent_version
|
from app.services.agent_version import (
|
||||||
|
effective_reference_version,
|
||||||
|
host_version_outdated,
|
||||||
|
is_agent_version_outdated,
|
||||||
|
latest_agent_versions_by_product,
|
||||||
|
parse_agent_version,
|
||||||
|
)
|
||||||
from app.models import Host
|
from app.models import Host
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -27,6 +33,61 @@ def test_outdated_different_minor_or_major():
|
|||||||
assert is_agent_version_outdated("2.1.0-SAC", latest) is False
|
assert is_agent_version_outdated("2.1.0-SAC", latest) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_reference_version_picks_max():
|
||||||
|
assert (
|
||||||
|
effective_reference_version(
|
||||||
|
recommended="2.1.0-SAC",
|
||||||
|
fleet_latest="2.0.6-SAC",
|
||||||
|
min_version="",
|
||||||
|
)
|
||||||
|
== "2.1.0-SAC"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
effective_reference_version(
|
||||||
|
recommended="",
|
||||||
|
fleet_latest="2.0.6-SAC",
|
||||||
|
min_version="2.1.0-SAC",
|
||||||
|
)
|
||||||
|
== "2.1.0-SAC"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
effective_reference_version(
|
||||||
|
recommended="",
|
||||||
|
fleet_latest="2.0.35-SAC",
|
||||||
|
min_version="",
|
||||||
|
)
|
||||||
|
== "2.0.35-SAC"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_version_outdated_ssh_vs_git():
|
||||||
|
assert (
|
||||||
|
host_version_outdated(
|
||||||
|
"2.0.6-SAC",
|
||||||
|
recommended="2.1.0-SAC",
|
||||||
|
fleet_latest="2.0.6-SAC",
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
host_version_outdated(
|
||||||
|
"2.0.6-SAC",
|
||||||
|
recommended="",
|
||||||
|
fleet_latest="2.0.6-SAC",
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
host_version_outdated(
|
||||||
|
"2.0.6-SAC",
|
||||||
|
recommended="",
|
||||||
|
fleet_latest="2.0.6-SAC",
|
||||||
|
min_version="2.1.0-SAC",
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_latest_agent_versions_by_product(db_session):
|
def test_latest_agent_versions_by_product(db_session):
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
db_session.add_all(
|
db_session.add_all(
|
||||||
|
|||||||
@@ -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.12.1"
|
assert APP_VERSION == "0.20.0"
|
||||||
assert APP_NAME == "Security Alert Center"
|
assert APP_NAME == "Security Alert Center"
|
||||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.12.1"
|
assert APP_VERSION_LABEL == "Security Alert Center v.0.20.0"
|
||||||
|
|||||||
@@ -267,6 +267,8 @@ export interface HostListResponse {
|
|||||||
page: number;
|
page: number;
|
||||||
page_size: number;
|
page_size: number;
|
||||||
latest_agent_versions?: Record<string, string>;
|
latest_agent_versions?: Record<string, string>;
|
||||||
|
/** Эталон для «устарела»: max(git latest, min, max в fleet) */
|
||||||
|
reference_agent_versions?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProblemSummary {
|
export interface ProblemSummary {
|
||||||
@@ -548,9 +550,24 @@ export interface AgentUpdateSettings {
|
|||||||
min_rdp_version: string | null;
|
min_rdp_version: string | null;
|
||||||
min_ssh_version: string | null;
|
min_ssh_version: string | null;
|
||||||
win_agent_update_script: string | null;
|
win_agent_update_script: string | null;
|
||||||
|
rdp_git_repo_url: string | null;
|
||||||
|
ssh_git_repo_url: string | null;
|
||||||
|
git_branch: string;
|
||||||
|
git_latest_rdp_version: string | null;
|
||||||
|
git_latest_ssh_version: string | null;
|
||||||
|
git_fetched_at: string | null;
|
||||||
|
git_errors: Record<string, string>;
|
||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentGitTestResult {
|
||||||
|
git_latest_rdp_version: string | null;
|
||||||
|
git_latest_ssh_version: string | null;
|
||||||
|
git_fetched_at: string | null;
|
||||||
|
git_errors: Record<string, string>;
|
||||||
|
from_cache: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAgentUpdateSettings(): Promise<AgentUpdateSettings> {
|
export function fetchAgentUpdateSettings(): Promise<AgentUpdateSettings> {
|
||||||
return apiFetch<AgentUpdateSettings>("/api/v1/settings/agent-updates");
|
return apiFetch<AgentUpdateSettings>("/api/v1/settings/agent-updates");
|
||||||
}
|
}
|
||||||
@@ -564,6 +581,12 @@ export function saveAgentUpdateSettings(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function testAgentGitRelease(): Promise<AgentGitTestResult> {
|
||||||
|
return apiFetch<AgentGitTestResult>("/api/v1/settings/agent-updates/test-git", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
events_last_24h: number;
|
events_last_24h: number;
|
||||||
hosts_total: number;
|
hosts_total: number;
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export function patchHostSummaryFromDetail(target: HostSummary, detail: HostDeta
|
|||||||
target.last_daily_report_at = detail.last_daily_report_at;
|
target.last_daily_report_at = detail.last_daily_report_at;
|
||||||
target.last_inventory_at = detail.last_inventory_at;
|
target.last_inventory_at = detail.last_inventory_at;
|
||||||
target.agent_status = detail.agent_status;
|
target.agent_status = detail.agent_status;
|
||||||
|
if (detail.version_status) {
|
||||||
|
target.version_status = detail.version_status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bumpLatestAgentVersion(
|
export function bumpLatestAgentVersion(
|
||||||
|
|||||||
@@ -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.12.1";
|
export const APP_VERSION = "0.20.0";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -202,16 +202,23 @@ 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 === "outdated") return true;
|
||||||
if (h.version_status === "ok") return false;
|
if (h.version_status === "ok") return false;
|
||||||
const latest = data.value?.latest_agent_versions?.[h.product];
|
const reference =
|
||||||
return isAgentVersionOutdated(h.product_version, latest);
|
data.value?.reference_agent_versions?.[h.product] ??
|
||||||
|
data.value?.latest_agent_versions?.[h.product];
|
||||||
|
return isAgentVersionOutdated(h.product_version, reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
function versionTitle(h: HostSummary): string {
|
function versionTitle(h: HostSummary): string {
|
||||||
const latest = data.value?.latest_agent_versions?.[h.product];
|
const reference =
|
||||||
|
data.value?.reference_agent_versions?.[h.product] ??
|
||||||
|
data.value?.latest_agent_versions?.[h.product];
|
||||||
if (!isVersionOutdated(h)) return "";
|
if (!isVersionOutdated(h)) return "";
|
||||||
return latest
|
if (!reference) return "Устаревшая версия агента";
|
||||||
? `Устарела относительно последней для ${h.product}: ${latest}`
|
const fromSettings = data.value?.reference_agent_versions?.[h.product];
|
||||||
: "Устаревшая версия агента";
|
if (fromSettings && fromSettings !== data.value?.latest_agent_versions?.[h.product]) {
|
||||||
|
return `Устарела относительно эталона ${reference} (настройки SAC)`;
|
||||||
|
}
|
||||||
|
return `Устарела относительно эталона ${reference}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openHost(id: number) {
|
function openHost(id: number) {
|
||||||
|
|||||||
@@ -126,6 +126,8 @@
|
|||||||
Режим <code>gpo</code> — обновления вручную/GPO; SAC только показывает устаревшие версии.
|
Режим <code>gpo</code> — обновления вручную/GPO; SAC только показывает устаревшие версии.
|
||||||
Режим <code>sac</code> — «Запросить обновление» ставит флаг в poll; агент self-update;
|
Режим <code>sac</code> — «Запросить обновление» ставит флаг в poll; агент self-update;
|
||||||
через {{ agentUpdateForm.fallback_after_minutes }} мин без ответа — fallback SSH/WinRM с SAC.
|
через {{ agentUpdateForm.fallback_after_minutes }} мин без ответа — fallback SSH/WinRM с SAC.
|
||||||
|
<strong>Эталон «устарела»</strong> берётся из <code>version.txt</code> / скриптов в git-репозиториях
|
||||||
|
(ветка <code>main</code> по умолчанию); при недоступности git — fallback на max версию в fleet.
|
||||||
</p>
|
</p>
|
||||||
<form class="settings-form" @submit.prevent="saveAgentUpdateSettingsForm">
|
<form class="settings-form" @submit.prevent="saveAgentUpdateSettingsForm">
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
@@ -144,13 +146,34 @@
|
|||||||
<input v-model.number="agentUpdateForm.fallback_after_minutes" type="number" min="1" max="1440" />
|
<input v-model.number="agentUpdateForm.fallback_after_minutes" type="number" min="1" max="1440" />
|
||||||
</label>
|
</label>
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
Recommended RDP-login-monitor
|
Git: RDP-login-monitor
|
||||||
<input v-model="agentUpdateForm.recommended_rdp_version" type="text" placeholder="2.1.0-SAC" />
|
<input
|
||||||
|
v-model="agentUpdateForm.rdp_git_repo_url"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
Recommended ssh-monitor
|
Git: ssh-monitor
|
||||||
<input v-model="agentUpdateForm.recommended_ssh_version" type="text" placeholder="2.1.0-SAC" />
|
<input
|
||||||
|
v-model="agentUpdateForm.ssh_git_repo_url"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
Git-ветка
|
||||||
|
<input v-model="agentUpdateForm.git_branch" type="text" placeholder="main" />
|
||||||
|
</label>
|
||||||
|
<p v-if="agentUpdateLoaded?.git_latest_rdp_version || agentUpdateLoaded?.git_latest_ssh_version" class="settings-meta">
|
||||||
|
Latest из git:
|
||||||
|
RDP <code>{{ agentUpdateLoaded?.git_latest_rdp_version ?? "—" }}</code>,
|
||||||
|
SSH <code>{{ agentUpdateLoaded?.git_latest_ssh_version ?? "—" }}</code>
|
||||||
|
<span v-if="agentUpdateLoaded?.git_fetched_at">
|
||||||
|
· обновлено {{ formatDateTime(agentUpdateLoaded.git_fetched_at) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="agentGitTestErrors" class="settings-error-inline">{{ agentGitTestErrors }}</p>
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
WinRM: путь к Deploy-LoginMonitor.ps1 (fallback)
|
WinRM: путь к Deploy-LoginMonitor.ps1 (fallback)
|
||||||
<input
|
<input
|
||||||
@@ -163,6 +186,9 @@
|
|||||||
Источник: <code>{{ agentUpdateLoaded.source }}</code>
|
Источник: <code>{{ agentUpdateLoaded.source }}</code>
|
||||||
</p>
|
</p>
|
||||||
<div class="settings-actions">
|
<div class="settings-actions">
|
||||||
|
<button type="button" :disabled="testingAgentGit" @click="runAgentGitTest">
|
||||||
|
{{ testingAgentGit ? "Проверка git…" : "Проверить git" }}
|
||||||
|
</button>
|
||||||
<button type="submit" :disabled="savingAgentUpdate">
|
<button type="submit" :disabled="savingAgentUpdate">
|
||||||
{{ savingAgentUpdate ? "Сохранение…" : "Сохранить обновления агентов" }}
|
{{ savingAgentUpdate ? "Сохранение…" : "Сохранить обновления агентов" }}
|
||||||
</button>
|
</button>
|
||||||
@@ -566,6 +592,7 @@ import {
|
|||||||
fetchLinuxAdminSettings,
|
fetchLinuxAdminSettings,
|
||||||
fetchAgentUpdateSettings,
|
fetchAgentUpdateSettings,
|
||||||
saveAgentUpdateSettings,
|
saveAgentUpdateSettings,
|
||||||
|
testAgentGitRelease,
|
||||||
revokeMobileDevice,
|
revokeMobileDevice,
|
||||||
deleteMobileDevice,
|
deleteMobileDevice,
|
||||||
revokeMobileEnrollmentCode,
|
revokeMobileEnrollmentCode,
|
||||||
@@ -658,6 +685,8 @@ 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 savingAgentUpdate = ref(false);
|
||||||
|
const testingAgentGit = ref(false);
|
||||||
|
const agentGitTestErrors = ref("");
|
||||||
const savingTelegram = ref(false);
|
const savingTelegram = ref(false);
|
||||||
const savingWebhook = ref(false);
|
const savingWebhook = ref(false);
|
||||||
const savingEmail = ref(false);
|
const savingEmail = ref(false);
|
||||||
@@ -754,8 +783,9 @@ const agentUpdateForm = reactive({
|
|||||||
mode: "gpo" as "gpo" | "sac",
|
mode: "gpo" as "gpo" | "sac",
|
||||||
fallback_enabled: true,
|
fallback_enabled: true,
|
||||||
fallback_after_minutes: 15,
|
fallback_after_minutes: 15,
|
||||||
recommended_rdp_version: "",
|
rdp_git_repo_url: "",
|
||||||
recommended_ssh_version: "",
|
ssh_git_repo_url: "",
|
||||||
|
git_branch: "main",
|
||||||
win_agent_update_script: "",
|
win_agent_update_script: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -973,15 +1003,51 @@ async function saveLinuxAdminSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAgentUpdateSettings() {
|
async function loadAgentUpdateSettings() {
|
||||||
const data = await fetchAgentUpdateSettings();
|
const data = await fetchAgentUpdateSettings();
|
||||||
agentUpdateLoaded.value = data;
|
agentUpdateLoaded.value = data;
|
||||||
agentUpdateForm.mode = data.mode;
|
agentUpdateForm.mode = data.mode;
|
||||||
agentUpdateForm.fallback_enabled = data.fallback_enabled;
|
agentUpdateForm.fallback_enabled = data.fallback_enabled;
|
||||||
agentUpdateForm.fallback_after_minutes = data.fallback_after_minutes;
|
agentUpdateForm.fallback_after_minutes = data.fallback_after_minutes;
|
||||||
agentUpdateForm.recommended_rdp_version = data.recommended_rdp_version ?? "";
|
agentUpdateForm.rdp_git_repo_url = data.rdp_git_repo_url ?? "";
|
||||||
agentUpdateForm.recommended_ssh_version = data.recommended_ssh_version ?? "";
|
agentUpdateForm.ssh_git_repo_url = data.ssh_git_repo_url ?? "";
|
||||||
|
agentUpdateForm.git_branch = data.git_branch ?? "main";
|
||||||
agentUpdateForm.win_agent_update_script = data.win_agent_update_script ?? "";
|
agentUpdateForm.win_agent_update_script = data.win_agent_update_script ?? "";
|
||||||
|
agentGitTestErrors.value = Object.values(data.git_errors ?? {}).join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAgentGitTest() {
|
||||||
|
testingAgentGit.value = true;
|
||||||
|
agentGitTestErrors.value = "";
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const result = await testAgentGitRelease();
|
||||||
|
if (agentUpdateLoaded.value) {
|
||||||
|
agentUpdateLoaded.value = {
|
||||||
|
...agentUpdateLoaded.value,
|
||||||
|
git_latest_rdp_version: result.git_latest_rdp_version,
|
||||||
|
git_latest_ssh_version: result.git_latest_ssh_version,
|
||||||
|
git_fetched_at: result.git_fetched_at,
|
||||||
|
git_errors: result.git_errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const errs = Object.values(result.git_errors ?? {});
|
||||||
|
agentGitTestErrors.value = errs.length ? errs.join("; ") : "";
|
||||||
|
success.value = result.from_cache
|
||||||
|
? "Версии из кэша (git не менялся)."
|
||||||
|
: "Версии успешно прочитаны из git.";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка проверки git";
|
||||||
|
} finally {
|
||||||
|
testingAgentGit.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAgentUpdateSettingsForm() {
|
async function saveAgentUpdateSettingsForm() {
|
||||||
@@ -993,10 +1059,12 @@ async function saveAgentUpdateSettingsForm() {
|
|||||||
mode: agentUpdateForm.mode,
|
mode: agentUpdateForm.mode,
|
||||||
fallback_enabled: agentUpdateForm.fallback_enabled,
|
fallback_enabled: agentUpdateForm.fallback_enabled,
|
||||||
fallback_after_minutes: agentUpdateForm.fallback_after_minutes,
|
fallback_after_minutes: agentUpdateForm.fallback_after_minutes,
|
||||||
recommended_rdp_version: agentUpdateForm.recommended_rdp_version.trim() || null,
|
rdp_git_repo_url: agentUpdateForm.rdp_git_repo_url.trim() || null,
|
||||||
recommended_ssh_version: agentUpdateForm.recommended_ssh_version.trim() || null,
|
ssh_git_repo_url: agentUpdateForm.ssh_git_repo_url.trim() || null,
|
||||||
|
git_branch: agentUpdateForm.git_branch.trim() || "main",
|
||||||
win_agent_update_script: agentUpdateForm.win_agent_update_script.trim() || null,
|
win_agent_update_script: agentUpdateForm.win_agent_update_script.trim() || null,
|
||||||
});
|
});
|
||||||
|
agentGitTestErrors.value = Object.values(agentUpdateLoaded.value.git_errors ?? {}).join("; ");
|
||||||
success.value = "Настройки обновления агентов сохранены.";
|
success.value = "Настройки обновления агентов сохранены.";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Ошибка сохранения agent-updates";
|
error.value = e instanceof Error ? e.message : "Ошибка сохранения agent-updates";
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run agent git fetch test on SAC server."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
SUDO_PW = "[eqyfhskj\n"
|
||||||
|
REMOTE_CMD = (
|
||||||
|
"set -e\n"
|
||||||
|
"TMP=$(mktemp -d)\n"
|
||||||
|
'trap "rm -rf \\"$TMP\\"" EXIT\n'
|
||||||
|
"git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git \"$TMP/rdp\"\n"
|
||||||
|
"git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/ssh-monitor.git \"$TMP/ssh\"\n"
|
||||||
|
"echo RDP_version_txt:\n"
|
||||||
|
"cat \"$TMP/rdp/version.txt\" 2>/dev/null || echo missing\n"
|
||||||
|
"echo RDP_script:\n"
|
||||||
|
"grep -m1 ScriptVersion \"$TMP/rdp/Login_Monitor.ps1\" 2>/dev/null || echo missing\n"
|
||||||
|
"echo SSH_version:\n"
|
||||||
|
"grep -m1 '^SSH_MONITOR_VERSION=' \"$TMP/ssh/ssh-monitor\" 2>/dev/null || cat \"$TMP/ssh/version.txt\" 2>/dev/null || echo missing\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
r = subprocess.run(
|
||||||
|
["ssh", "sac.kalinamall.ru", "sudo", "-S", "-u", "sac", "bash", "-s"],
|
||||||
|
input=SUDO_PW + REMOTE_CMD,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
print(r.stdout)
|
||||||
|
if r.stderr:
|
||||||
|
print("STDERR:", r.stderr[-400:])
|
||||||
|
print("exit", r.returncode)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git "$TMP/rdp"
|
||||||
|
git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/ssh-monitor.git "$TMP/ssh"
|
||||||
|
echo RDP_version_txt:
|
||||||
|
cat "$TMP/rdp/version.txt" 2>/dev/null || echo missing
|
||||||
|
echo RDP_script:
|
||||||
|
grep -m1 ScriptVersion "$TMP/rdp/Login_Monitor.ps1" 2>/dev/null || echo missing
|
||||||
|
echo SSH_version:
|
||||||
|
grep -m1 '^SSH_MONITOR_VERSION=' "$TMP/ssh/ssh-monitor" 2>/dev/null || cat "$TMP/ssh/version.txt" 2>/dev/null || echo missing
|
||||||
Reference in New Issue
Block a user