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.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 [],
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -267,6 +267,8 @@ export interface HostListResponse {
|
||||
page: number;
|
||||
page_size: number;
|
||||
latest_agent_versions?: Record<string, string>;
|
||||
/** Эталон для «устарела»: max(git latest, min, max в fleet) */
|
||||
reference_agent_versions?: Record<string, string>;
|
||||
}
|
||||
|
||||
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<string, 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> {
|
||||
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 {
|
||||
events_last_24h: 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_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(
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -126,6 +126,8 @@
|
||||
Режим <code>gpo</code> — обновления вручную/GPO; SAC только показывает устаревшие версии.
|
||||
Режим <code>sac</code> — «Запросить обновление» ставит флаг в poll; агент self-update;
|
||||
через {{ agentUpdateForm.fallback_after_minutes }} мин без ответа — fallback SSH/WinRM с SAC.
|
||||
<strong>Эталон «устарела»</strong> берётся из <code>version.txt</code> / скриптов в git-репозиториях
|
||||
(ветка <code>main</code> по умолчанию); при недоступности git — fallback на max версию в fleet.
|
||||
</p>
|
||||
<form class="settings-form" @submit.prevent="saveAgentUpdateSettingsForm">
|
||||
<label class="settings-field">
|
||||
@@ -144,13 +146,34 @@
|
||||
<input v-model.number="agentUpdateForm.fallback_after_minutes" type="number" min="1" max="1440" />
|
||||
</label>
|
||||
<label class="settings-field">
|
||||
Recommended RDP-login-monitor
|
||||
<input v-model="agentUpdateForm.recommended_rdp_version" type="text" placeholder="2.1.0-SAC" />
|
||||
Git: RDP-login-monitor
|
||||
<input
|
||||
v-model="agentUpdateForm.rdp_git_repo_url"
|
||||
type="text"
|
||||
placeholder="https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
|
||||
/>
|
||||
</label>
|
||||
<label class="settings-field">
|
||||
Recommended ssh-monitor
|
||||
<input v-model="agentUpdateForm.recommended_ssh_version" type="text" placeholder="2.1.0-SAC" />
|
||||
Git: ssh-monitor
|
||||
<input
|
||||
v-model="agentUpdateForm.ssh_git_repo_url"
|
||||
type="text"
|
||||
placeholder="https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
|
||||
/>
|
||||
</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">
|
||||
WinRM: путь к Deploy-LoginMonitor.ps1 (fallback)
|
||||
<input
|
||||
@@ -163,6 +186,9 @@
|
||||
Источник: <code>{{ agentUpdateLoaded.source }}</code>
|
||||
</p>
|
||||
<div class="settings-actions">
|
||||
<button type="button" :disabled="testingAgentGit" @click="runAgentGitTest">
|
||||
{{ testingAgentGit ? "Проверка git…" : "Проверить git" }}
|
||||
</button>
|
||||
<button type="submit" :disabled="savingAgentUpdate">
|
||||
{{ savingAgentUpdate ? "Сохранение…" : "Сохранить обновления агентов" }}
|
||||
</button>
|
||||
@@ -566,6 +592,7 @@ import {
|
||||
fetchLinuxAdminSettings,
|
||||
fetchAgentUpdateSettings,
|
||||
saveAgentUpdateSettings,
|
||||
testAgentGitRelease,
|
||||
revokeMobileDevice,
|
||||
deleteMobileDevice,
|
||||
revokeMobileEnrollmentCode,
|
||||
@@ -658,6 +685,8 @@ const savingUi = ref(false);
|
||||
const savingWinAdmin = ref(false);
|
||||
const savingLinuxAdmin = ref(false);
|
||||
const savingAgentUpdate = ref(false);
|
||||
const testingAgentGit = ref(false);
|
||||
const agentGitTestErrors = ref("");
|
||||
const savingTelegram = ref(false);
|
||||
const savingWebhook = ref(false);
|
||||
const savingEmail = ref(false);
|
||||
@@ -754,8 +783,9 @@ const agentUpdateForm = reactive({
|
||||
mode: "gpo" as "gpo" | "sac",
|
||||
fallback_enabled: true,
|
||||
fallback_after_minutes: 15,
|
||||
recommended_rdp_version: "",
|
||||
recommended_ssh_version: "",
|
||||
rdp_git_repo_url: "",
|
||||
ssh_git_repo_url: "",
|
||||
git_branch: "main",
|
||||
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() {
|
||||
const data = await fetchAgentUpdateSettings();
|
||||
agentUpdateLoaded.value = data;
|
||||
agentUpdateForm.mode = data.mode;
|
||||
agentUpdateForm.fallback_enabled = data.fallback_enabled;
|
||||
agentUpdateForm.fallback_after_minutes = data.fallback_after_minutes;
|
||||
agentUpdateForm.recommended_rdp_version = data.recommended_rdp_version ?? "";
|
||||
agentUpdateForm.recommended_ssh_version = data.recommended_ssh_version ?? "";
|
||||
agentUpdateForm.rdp_git_repo_url = data.rdp_git_repo_url ?? "";
|
||||
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 ?? "";
|
||||
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() {
|
||||
@@ -993,10 +1059,12 @@ async function saveAgentUpdateSettingsForm() {
|
||||
mode: agentUpdateForm.mode,
|
||||
fallback_enabled: agentUpdateForm.fallback_enabled,
|
||||
fallback_after_minutes: agentUpdateForm.fallback_after_minutes,
|
||||
recommended_rdp_version: agentUpdateForm.recommended_rdp_version.trim() || null,
|
||||
recommended_ssh_version: agentUpdateForm.recommended_ssh_version.trim() || null,
|
||||
rdp_git_repo_url: agentUpdateForm.rdp_git_repo_url.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,
|
||||
});
|
||||
agentGitTestErrors.value = Object.values(agentUpdateLoaded.value.git_errors ?? {}).join("; ");
|
||||
success.value = "Настройки обновления агентов сохранены.";
|
||||
} catch (e) {
|
||||
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