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?: Record; + /** Эталон для «устарела»: max(git latest, min, max в fleet) */ + reference_agent_versions?: Record; } export interface ProblemSummary { @@ -548,9 +550,24 @@ export interface AgentUpdateSettings { min_rdp_version: string | null; min_ssh_version: 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; 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; + from_cache: boolean; +} + export function fetchAgentUpdateSettings(): Promise { return apiFetch("/api/v1/settings/agent-updates"); } @@ -564,6 +581,12 @@ export function saveAgentUpdateSettings( }); } +export function testAgentGitRelease(): Promise { + return apiFetch("/api/v1/settings/agent-updates/test-git", { + method: "POST", + }); +} + export interface DashboardSummary { events_last_24h: number; hosts_total: number; diff --git a/frontend/src/utils/hostsLiveUpdate.ts b/frontend/src/utils/hostsLiveUpdate.ts index 9842275..4acf8a5 100644 --- a/frontend/src/utils/hostsLiveUpdate.ts +++ b/frontend/src/utils/hostsLiveUpdate.ts @@ -13,6 +13,9 @@ export function patchHostSummaryFromDetail(target: HostSummary, detail: HostDeta target.last_daily_report_at = detail.last_daily_report_at; target.last_inventory_at = detail.last_inventory_at; target.agent_status = detail.agent_status; + if (detail.version_status) { + target.version_status = detail.version_status; + } } export function bumpLatestAgentVersion( diff --git a/frontend/src/version.ts b/frontend/src/version.ts index fa6620b..af9e344 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.12.1"; +export const APP_VERSION = "0.20.0"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/HostsView.vue b/frontend/src/views/HostsView.vue index eae7af6..f31bb05 100644 --- a/frontend/src/views/HostsView.vue +++ b/frontend/src/views/HostsView.vue @@ -202,16 +202,23 @@ function agentLabel(status: string) { function isVersionOutdated(h: HostSummary): boolean { if (h.version_status === "outdated") return true; if (h.version_status === "ok") return false; - const latest = data.value?.latest_agent_versions?.[h.product]; - return isAgentVersionOutdated(h.product_version, latest); + const reference = + 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 { - 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 ""; - return latest - ? `Устарела относительно последней для ${h.product}: ${latest}` - : "Устаревшая версия агента"; + if (!reference) return "Устаревшая версия агента"; + 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) { diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index ef698ee..5e093dc 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -126,6 +126,8 @@ Режим gpo — обновления вручную/GPO; SAC только показывает устаревшие версии. Режим sac — «Запросить обновление» ставит флаг в poll; агент self-update; через {{ agentUpdateForm.fallback_after_minutes }} мин без ответа — fallback SSH/WinRM с SAC. + Эталон «устарела» берётся из version.txt / скриптов в git-репозиториях + (ветка main по умолчанию); при недоступности git — fallback на max версию в fleet.

+ +

+ Latest из git: + RDP {{ agentUpdateLoaded?.git_latest_rdp_version ?? "—" }}, + SSH {{ agentUpdateLoaded?.git_latest_ssh_version ?? "—" }} + + · обновлено {{ formatDateTime(agentUpdateLoaded.git_fetched_at) }} + +

+

{{ agentGitTestErrors }}