diff --git a/backend/alembic/versions/021_agent_git_release.py b/backend/alembic/versions/021_agent_git_release.py
new file mode 100644
index 0000000..dededd1
--- /dev/null
+++ b/backend/alembic/versions/021_agent_git_release.py
@@ -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")
diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py
index 114dd75..a36bfb7 100644
--- a/backend/app/api/v1/hosts.py
+++ b/backend/app/api/v1/hosts.py
@@ -10,7 +10,11 @@ from app.config import get_settings
from app.database import get_db
from app.models import Event, Host
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
-from app.services.agent_version import latest_agent_versions_by_product
+from app.services.agent_version import (
+ latest_agent_versions_by_product,
+ reference_agent_versions_by_product,
+)
+from app.services.agent_git_release import get_git_release_versions
from app.services.agent_update import (
AgentUpdateNotAllowedError,
execute_agent_update_fallback,
@@ -92,6 +96,12 @@ def list_hosts(
report_map = max_daily_report_time_by_host(db)
latest_map = latest_agent_versions_by_product(db)
update_cfg = get_effective_agent_update_config(db)
+ git_release = get_git_release_versions(update_cfg, db=db)
+ reference_map = reference_agent_versions_by_product(
+ update_cfg,
+ latest_map,
+ git_versions=git_release.versions,
+ )
items: list[HostSummary] = []
for host in rows:
@@ -117,7 +127,12 @@ def list_hosts(
agent_status=compute_agent_status(
last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes
),
- version_status=host_version_status(host, cfg=update_cfg, latest_map=latest_map),
+ 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),
)
)
@@ -127,7 +142,8 @@ def list_hosts(
total=total,
page=page,
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)
latest_map = latest_agent_versions_by_product(db)
update_cfg = get_effective_agent_update_config(db)
+ git_release = get_git_release_versions(update_cfg, db=db)
return HostDetail(
id=host.id,
hostname=host.hostname,
@@ -161,7 +178,12 @@ def _host_detail_from_model(
last_daily_report_at=report_map.get(host.id),
last_inventory_at=host.inventory_updated_at,
agent_status=compute_agent_status(last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes),
- version_status=host_version_status(host, cfg=update_cfg, latest_map=latest_map),
+ version_status=host_version_status(
+ host,
+ cfg=update_cfg,
+ latest_map=latest_map,
+ git_versions=git_release.versions,
+ ),
pending_agent_update=bool(host.pending_agent_update),
agent_instance_id=host.agent_instance_id,
tags=host.tags if isinstance(host.tags, list) else [],
diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py
index d80ae81..f5913a2 100644
--- a/backend/app/api/v1/settings.py
+++ b/backend/app/api/v1/settings.py
@@ -1,3 +1,5 @@
+from datetime import datetime
+
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -45,6 +47,7 @@ from app.services.win_admin_settings import (
get_effective_win_admin_config,
upsert_win_admin_settings,
)
+from app.services.agent_git_release import get_git_release_versions
from app.services.agent_update_settings import (
get_effective_agent_update_config,
upsert_agent_update_settings,
@@ -555,6 +558,13 @@ class AgentUpdateSettingsResponse(BaseModel):
min_rdp_version: str | None = None
min_ssh_version: 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")
@@ -567,9 +577,31 @@ class AgentUpdateSettingsUpdate(BaseModel):
min_rdp_version: str | None = None
min_ssh_version: 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(
mode=cfg.mode,
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_ssh_version=cfg.min_ssh_version 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,
)
@@ -588,7 +627,9 @@ 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))
+ 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)
@@ -608,7 +649,29 @@ def update_agent_update_settings(
min_rdp_version=body.min_rdp_version,
min_ssh_version=body.min_ssh_version,
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:
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,
+ )
diff --git a/backend/app/config.py b/backend/app/config.py
index d855c97..502f8cc 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -124,6 +124,12 @@ class Settings(BaseSettings):
sac_agent_min_rdp_version: str = ""
sac_agent_min_ssh_version: 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)
sac_events_retention_days: int = 90
diff --git a/backend/app/models/ui_settings.py b/backend/app/models/ui_settings.py
index 058014f..165070b 100644
--- a/backend/app/models/ui_settings.py
+++ b/backend/app/models/ui_settings.py
@@ -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 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_ssh_version: Mapped[str | None] = mapped_column(String(64), 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)
diff --git a/backend/app/schemas/list_models.py b/backend/app/schemas/list_models.py
index 6f89b9c..79005a6 100644
--- a/backend/app/schemas/list_models.py
+++ b/backend/app/schemas/list_models.py
@@ -49,6 +49,10 @@ class HostListResponse(BaseModel):
page: int
page_size: int
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):
diff --git a/backend/app/services/agent_git_release.py b/backend/app/services/agent_git_release.py
new file mode 100644
index 0000000..ef0f10c
--- /dev/null
+++ b/backend/app/services/agent_git_release.py
@@ -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,
+ )
diff --git a/backend/app/services/agent_update.py b/backend/app/services/agent_update.py
index f12db17..34301b8 100644
--- a/backend/app/services/agent_update.py
+++ b/backend/app/services/agent_update.py
@@ -14,10 +14,10 @@ from app.services.agent_update_settings import (
AgentUpdateConfig,
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 (
- is_agent_version_outdated,
+ host_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 (
@@ -49,12 +49,21 @@ 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
+def resolve_target_version(
+ db: Session,
+ host: Host,
+ 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)
- 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(
@@ -62,17 +71,20 @@ def host_version_status(
*,
cfg: AgentUpdateConfig,
latest_map: dict[str, str],
+ git_versions: dict[str, str] | None = None,
) -> str:
- current = host.product_version
- if not current or parse_agent_version(current) is None:
+ product = host.product or ""
+ 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"
- 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"
+ return "outdated" if outdated else "ok"
def request_agent_update(db: Session, host: Host) -> Host:
diff --git a/backend/app/services/agent_update_settings.py b/backend/app/services/agent_update_settings.py
index 4e07356..3a07dad 100644
--- a/backend/app/services/agent_update_settings.py
+++ b/backend/app/services/agent_update_settings.py
@@ -24,6 +24,9 @@ class AgentUpdateConfig:
min_rdp_version: str
min_ssh_version: str
win_agent_update_script: str
+ rdp_git_repo_url: str
+ ssh_git_repo_url: str
+ git_branch: str
source: str # env | db
@property
@@ -59,6 +62,9 @@ def _agent_update_from_env() -> AgentUpdateConfig:
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(),
+ 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",
)
@@ -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_ssh_version 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,
win_agent_update_script=(row.win_agent_update_script or "").strip()
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,
)
@@ -131,6 +143,9 @@ def upsert_agent_update_settings(
min_rdp_version: str | None = None,
min_ssh_version: 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:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
@@ -156,6 +171,21 @@ def upsert_agent_update_settings(
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
+ 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.refresh(row)
diff --git a/backend/app/services/agent_version.py b/backend/app/services/agent_version.py
index 3d8b1ec..12dbf8b 100644
--- a/backend/app/services/agent_version.py
+++ b/backend/app/services/agent_version.py
@@ -60,6 +60,75 @@ def is_agent_version_outdated(
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]:
rows = db.execute(
select(Host.product, Host.product_version).where(
diff --git a/backend/app/version.py b/backend/app/version.py
index c745aee..46b1bc7 100644
--- a/backend/app/version.py
+++ b/backend/app/version.py
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
-APP_VERSION = "0.12.1"
+APP_VERSION = "0.20.0"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 50fb876..b1b2054 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -80,6 +80,30 @@ def db_session(db_engine):
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
def client(db_session, monkeypatch):
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
diff --git a/backend/tests/test_agent_git_release.py b/backend/tests/test_agent_git_release.py
new file mode 100644
index 0000000..a224def
--- /dev/null
+++ b/backend/tests/test_agent_git_release.py
@@ -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"
diff --git a/backend/tests/test_agent_version.py b/backend/tests/test_agent_version.py
index e118e12..f1f7de4 100644
--- a/backend/tests/test_agent_version.py
+++ b/backend/tests/test_agent_version.py
@@ -1,6 +1,12 @@
"""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 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
+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):
now = datetime.now(timezone.utc)
db_session.add_all(
diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py
index e4f71ec..1d967e2 100644
--- a/backend/tests/test_health.py
+++ b/backend/tests/test_health.py
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants():
- assert APP_VERSION == "0.12.1"
+ assert APP_VERSION == "0.20.0"
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"
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 0f3fa01..076de7e 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -267,6 +267,8 @@ export interface HostListResponse {
page: number;
page_size: number;
latest_agent_versions?: Recordgpo — обновления вручную/GPO; SAC только показывает устаревшие версии.
Режим sac — «Запросить обновление» ставит флаг в poll; агент self-update;
через {{ agentUpdateForm.fallback_after_minutes }} мин без ответа — fallback SSH/WinRM с SAC.
+ Эталон «устарела» берётся из version.txt / скриптов в git-репозиториях
+ (ветка main по умолчанию); при недоступности git — fallback на max версию в fleet.