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:
2026-06-20 16:25:53 +10:00
parent d820d941c1
commit f411d8f070
22 changed files with 932 additions and 45 deletions
@@ -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")
+26 -4
View File
@@ -10,7 +10,11 @@ from app.config import get_settings
from app.database import get_db from app.database import get_db
from app.models import Event, Host from app.models import Event, Host
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
from app.services.agent_version import latest_agent_versions_by_product from app.services.agent_version import (
latest_agent_versions_by_product,
reference_agent_versions_by_product,
)
from app.services.agent_git_release import get_git_release_versions
from app.services.agent_update import ( from app.services.agent_update import (
AgentUpdateNotAllowedError, AgentUpdateNotAllowedError,
execute_agent_update_fallback, execute_agent_update_fallback,
@@ -92,6 +96,12 @@ def list_hosts(
report_map = max_daily_report_time_by_host(db) report_map = max_daily_report_time_by_host(db)
latest_map = latest_agent_versions_by_product(db) latest_map = latest_agent_versions_by_product(db)
update_cfg = get_effective_agent_update_config(db) update_cfg = get_effective_agent_update_config(db)
git_release = get_git_release_versions(update_cfg, db=db)
reference_map = reference_agent_versions_by_product(
update_cfg,
latest_map,
git_versions=git_release.versions,
)
items: list[HostSummary] = [] items: list[HostSummary] = []
for host in rows: for host in rows:
@@ -117,7 +127,12 @@ def list_hosts(
agent_status=compute_agent_status( agent_status=compute_agent_status(
last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes
), ),
version_status=host_version_status(host, cfg=update_cfg, latest_map=latest_map), version_status=host_version_status(
host,
cfg=update_cfg,
latest_map=latest_map,
git_versions=git_release.versions,
),
pending_agent_update=bool(host.pending_agent_update), pending_agent_update=bool(host.pending_agent_update),
) )
) )
@@ -127,7 +142,8 @@ def list_hosts(
total=total, total=total,
page=page, page=page,
page_size=page_size, page_size=page_size,
latest_agent_versions=latest_agent_versions_by_product(db), latest_agent_versions=latest_map,
reference_agent_versions=reference_map,
) )
@@ -145,6 +161,7 @@ def _host_detail_from_model(
last_hb = hb_map.get(host.id) last_hb = hb_map.get(host.id)
latest_map = latest_agent_versions_by_product(db) latest_map = latest_agent_versions_by_product(db)
update_cfg = get_effective_agent_update_config(db) update_cfg = get_effective_agent_update_config(db)
git_release = get_git_release_versions(update_cfg, db=db)
return HostDetail( return HostDetail(
id=host.id, id=host.id,
hostname=host.hostname, hostname=host.hostname,
@@ -161,7 +178,12 @@ def _host_detail_from_model(
last_daily_report_at=report_map.get(host.id), last_daily_report_at=report_map.get(host.id),
last_inventory_at=host.inventory_updated_at, last_inventory_at=host.inventory_updated_at,
agent_status=compute_agent_status(last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes), agent_status=compute_agent_status(last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes),
version_status=host_version_status(host, cfg=update_cfg, latest_map=latest_map), version_status=host_version_status(
host,
cfg=update_cfg,
latest_map=latest_map,
git_versions=git_release.versions,
),
pending_agent_update=bool(host.pending_agent_update), pending_agent_update=bool(host.pending_agent_update),
agent_instance_id=host.agent_instance_id, agent_instance_id=host.agent_instance_id,
tags=host.tags if isinstance(host.tags, list) else [], tags=host.tags if isinstance(host.tags, list) else [],
+66 -3
View File
@@ -1,3 +1,5 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -45,6 +47,7 @@ from app.services.win_admin_settings import (
get_effective_win_admin_config, get_effective_win_admin_config,
upsert_win_admin_settings, upsert_win_admin_settings,
) )
from app.services.agent_git_release import get_git_release_versions
from app.services.agent_update_settings import ( from app.services.agent_update_settings import (
get_effective_agent_update_config, get_effective_agent_update_config,
upsert_agent_update_settings, upsert_agent_update_settings,
@@ -555,6 +558,13 @@ class AgentUpdateSettingsResponse(BaseModel):
min_rdp_version: str | None = None min_rdp_version: str | None = None
min_ssh_version: str | None = None min_ssh_version: str | None = None
win_agent_update_script: str | None = None win_agent_update_script: str | None = None
rdp_git_repo_url: str | None = None
ssh_git_repo_url: str | None = None
git_branch: str = "main"
git_latest_rdp_version: str | None = None
git_latest_ssh_version: str | None = None
git_fetched_at: datetime | None = None
git_errors: dict[str, str] = Field(default_factory=dict)
source: str = Field(description="env или db") source: str = Field(description="env или db")
@@ -567,9 +577,31 @@ class AgentUpdateSettingsUpdate(BaseModel):
min_rdp_version: str | None = None min_rdp_version: str | None = None
min_ssh_version: str | None = None min_ssh_version: str | None = None
win_agent_update_script: str | None = None win_agent_update_script: str | None = None
rdp_git_repo_url: str | None = None
ssh_git_repo_url: str | None = None
git_branch: str | None = None
def _agent_update_to_response(cfg) -> AgentUpdateSettingsResponse: class AgentGitTestResponse(BaseModel):
git_latest_rdp_version: str | None = None
git_latest_ssh_version: str | None = None
git_fetched_at: datetime | None = None
git_errors: dict[str, str] = Field(default_factory=dict)
from_cache: bool = False
def _agent_update_to_response(cfg, git_release=None) -> AgentUpdateSettingsResponse:
git_latest_rdp = None
git_latest_ssh = None
git_fetched_at = None
git_errors: dict[str, str] = {}
if git_release is not None:
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
git_latest_rdp = git_release.versions.get(PRODUCT_RDP) or None
git_latest_ssh = git_release.versions.get(PRODUCT_SSH) or None
git_fetched_at = git_release.fetched_at
git_errors = dict(git_release.errors)
return AgentUpdateSettingsResponse( return AgentUpdateSettingsResponse(
mode=cfg.mode, mode=cfg.mode,
fallback_enabled=cfg.fallback_enabled, fallback_enabled=cfg.fallback_enabled,
@@ -579,6 +611,13 @@ def _agent_update_to_response(cfg) -> AgentUpdateSettingsResponse:
min_rdp_version=cfg.min_rdp_version or None, min_rdp_version=cfg.min_rdp_version or None,
min_ssh_version=cfg.min_ssh_version or None, min_ssh_version=cfg.min_ssh_version or None,
win_agent_update_script=cfg.win_agent_update_script or None, win_agent_update_script=cfg.win_agent_update_script or None,
rdp_git_repo_url=cfg.rdp_git_repo_url or None,
ssh_git_repo_url=cfg.ssh_git_repo_url or None,
git_branch=cfg.git_branch or "main",
git_latest_rdp_version=git_latest_rdp,
git_latest_ssh_version=git_latest_ssh,
git_fetched_at=git_fetched_at,
git_errors=git_errors,
source=cfg.source, source=cfg.source,
) )
@@ -588,7 +627,9 @@ def get_agent_update_settings(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user=Depends(require_admin), _user=Depends(require_admin),
) -> AgentUpdateSettingsResponse: ) -> AgentUpdateSettingsResponse:
return _agent_update_to_response(get_effective_agent_update_config(db)) cfg = get_effective_agent_update_config(db)
git_release = get_git_release_versions(cfg, db=db)
return _agent_update_to_response(cfg, git_release)
@router.put("/agent-updates", response_model=AgentUpdateSettingsResponse) @router.put("/agent-updates", response_model=AgentUpdateSettingsResponse)
@@ -608,7 +649,29 @@ def update_agent_update_settings(
min_rdp_version=body.min_rdp_version, min_rdp_version=body.min_rdp_version,
min_ssh_version=body.min_ssh_version, min_ssh_version=body.min_ssh_version,
win_agent_update_script=body.win_agent_update_script, win_agent_update_script=body.win_agent_update_script,
rdp_git_repo_url=body.rdp_git_repo_url,
ssh_git_repo_url=body.ssh_git_repo_url,
git_branch=body.git_branch,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
return _agent_update_to_response(cfg) git_release = get_git_release_versions(cfg, db=db, force_refresh=True)
return _agent_update_to_response(cfg, git_release)
@router.post("/agent-updates/test-git", response_model=AgentGitTestResponse)
def test_agent_git_release(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> AgentGitTestResponse:
cfg = get_effective_agent_update_config(db)
git_release = get_git_release_versions(cfg, db=db, force_refresh=True)
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
return AgentGitTestResponse(
git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None,
git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None,
git_fetched_at=git_release.fetched_at,
git_errors=dict(git_release.errors),
from_cache=git_release.from_cache,
)
+6
View File
@@ -124,6 +124,12 @@ class Settings(BaseSettings):
sac_agent_min_rdp_version: str = "" sac_agent_min_rdp_version: str = ""
sac_agent_min_ssh_version: str = "" sac_agent_min_ssh_version: str = ""
sac_win_agent_update_script: str = "" sac_win_agent_update_script: str = ""
sac_agent_rdp_git_repo_url: str = "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
sac_agent_ssh_git_repo_url: str = "https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
sac_agent_git_branch: str = "main"
sac_agent_git_cache_dir: str = "/opt/security-alert-center/cache/agent-repos"
sac_agent_git_cache_ttl_minutes: int = 30
sac_agent_git_token: str = ""
# Retention (app.jobs.retention / systemd timer) # Retention (app.jobs.retention / systemd timer)
sac_events_retention_days: int = 90 sac_events_retention_days: int = 90
+9 -1
View File
@@ -1,4 +1,6 @@
from sqlalchemy import Boolean, String, Text from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base from app.database import Base
@@ -25,3 +27,9 @@ class UiSettings(Base):
agent_min_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True) agent_min_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
agent_min_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True) agent_min_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
win_agent_update_script: Mapped[str | None] = mapped_column(Text, nullable=True) win_agent_update_script: Mapped[str | None] = mapped_column(Text, nullable=True)
agent_rdp_git_repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
agent_ssh_git_repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
agent_git_branch: Mapped[str] = mapped_column(String(64), default="main", nullable=False)
agent_git_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
agent_git_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
agent_git_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+4
View File
@@ -49,6 +49,10 @@ class HostListResponse(BaseModel):
page: int page: int
page_size: int page_size: int
latest_agent_versions: dict[str, str] = Field(default_factory=dict) latest_agent_versions: dict[str, str] = Field(default_factory=dict)
reference_agent_versions: dict[str, str] = Field(
default_factory=dict,
description="Эталон для «устарела»: max(git latest, min, max в fleet)",
)
class EventSummary(BaseModel): class EventSummary(BaseModel):
+308
View File
@@ -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,
)
+28 -16
View File
@@ -14,10 +14,10 @@ from app.services.agent_update_settings import (
AgentUpdateConfig, AgentUpdateConfig,
get_effective_agent_update_config, get_effective_agent_update_config,
) )
from app.services.agent_git_release import get_git_release_versions, recommended_from_git
from app.services.agent_version import ( from app.services.agent_version import (
is_agent_version_outdated, host_version_outdated,
latest_agent_versions_by_product, latest_agent_versions_by_product,
parse_agent_version,
) )
from app.services.linux_admin_settings import get_effective_linux_admin_config from app.services.linux_admin_settings import get_effective_linux_admin_config
from app.services.ssh_connect import ( from app.services.ssh_connect import (
@@ -49,12 +49,21 @@ class AgentUpdateNotAllowedError(Exception):
pass pass
def resolve_target_version(db: Session, host: Host, cfg: AgentUpdateConfig) -> str: def resolve_target_version(
recommended = cfg.recommended_for_product(host.product or "") db: Session,
if recommended: host: Host,
return recommended cfg: AgentUpdateConfig,
*,
git_versions: dict[str, str] | None = None,
) -> str:
product = host.product or ""
if git_versions is None:
git_versions = get_git_release_versions(cfg, db=db).versions
git_target = recommended_from_git(cfg, git_versions, product)
if git_target:
return git_target
latest_map = latest_agent_versions_by_product(db) latest_map = latest_agent_versions_by_product(db)
return latest_map.get(host.product or "", "") or (host.product_version or "") return latest_map.get(product, "") or (host.product_version or "")
def host_version_status( def host_version_status(
@@ -62,17 +71,20 @@ def host_version_status(
*, *,
cfg: AgentUpdateConfig, cfg: AgentUpdateConfig,
latest_map: dict[str, str], latest_map: dict[str, str],
git_versions: dict[str, str] | None = None,
) -> str: ) -> str:
current = host.product_version product = host.product or ""
if not current or parse_agent_version(current) is None: if git_versions is None:
git_versions = get_git_release_versions(cfg).versions
outdated = host_version_outdated(
host.product_version,
recommended=recommended_from_git(cfg, git_versions, product),
fleet_latest=latest_map.get(product, ""),
min_version=cfg.min_for_product(product),
)
if outdated is None:
return "unknown" return "unknown"
recommended = cfg.recommended_for_product(host.product or "") return "outdated" if outdated else "ok"
reference = recommended or latest_map.get(host.product or "")
if not reference:
return "unknown"
if is_agent_version_outdated(current, reference):
return "outdated"
return "ok"
def request_agent_update(db: Session, host: Host) -> Host: def request_agent_update(db: Session, host: Host) -> Host:
@@ -24,6 +24,9 @@ class AgentUpdateConfig:
min_rdp_version: str min_rdp_version: str
min_ssh_version: str min_ssh_version: str
win_agent_update_script: str win_agent_update_script: str
rdp_git_repo_url: str
ssh_git_repo_url: str
git_branch: str
source: str # env | db source: str # env | db
@property @property
@@ -59,6 +62,9 @@ def _agent_update_from_env() -> AgentUpdateConfig:
min_rdp_version=(settings.sac_agent_min_rdp_version or "").strip(), min_rdp_version=(settings.sac_agent_min_rdp_version or "").strip(),
min_ssh_version=(settings.sac_agent_min_ssh_version or "").strip(), min_ssh_version=(settings.sac_agent_min_ssh_version or "").strip(),
win_agent_update_script=(settings.sac_win_agent_update_script or "").strip(), win_agent_update_script=(settings.sac_win_agent_update_script or "").strip(),
rdp_git_repo_url=(settings.sac_agent_rdp_git_repo_url or "").strip(),
ssh_git_repo_url=(settings.sac_agent_ssh_git_repo_url or "").strip(),
git_branch=(settings.sac_agent_git_branch or "main").strip() or "main",
source="env", source="env",
) )
@@ -74,6 +80,9 @@ def _row_has_agent_update_values(row: UiSettings) -> bool:
bool((row.agent_min_rdp_version or "").strip()), bool((row.agent_min_rdp_version or "").strip()),
bool((row.agent_min_ssh_version or "").strip()), bool((row.agent_min_ssh_version or "").strip()),
bool((row.win_agent_update_script or "").strip()), bool((row.win_agent_update_script or "").strip()),
bool((row.agent_rdp_git_repo_url or "").strip()),
bool((row.agent_ssh_git_repo_url or "").strip()),
(row.agent_git_branch or "main").strip() not in ("", "main"),
] ]
) )
@@ -116,6 +125,9 @@ def get_effective_agent_update_config(db: Session | None = None) -> AgentUpdateC
min_ssh_version=(row.agent_min_ssh_version or "").strip() or env_cfg.min_ssh_version, min_ssh_version=(row.agent_min_ssh_version or "").strip() or env_cfg.min_ssh_version,
win_agent_update_script=(row.win_agent_update_script or "").strip() win_agent_update_script=(row.win_agent_update_script or "").strip()
or env_cfg.win_agent_update_script, or env_cfg.win_agent_update_script,
rdp_git_repo_url=(row.agent_rdp_git_repo_url or "").strip() or env_cfg.rdp_git_repo_url,
ssh_git_repo_url=(row.agent_ssh_git_repo_url or "").strip() or env_cfg.ssh_git_repo_url,
git_branch=(row.agent_git_branch or env_cfg.git_branch).strip() or env_cfg.git_branch,
source="db" if _row_has_agent_update_values(row) else env_cfg.source, source="db" if _row_has_agent_update_values(row) else env_cfg.source,
) )
@@ -131,6 +143,9 @@ def upsert_agent_update_settings(
min_rdp_version: str | None = None, min_rdp_version: str | None = None,
min_ssh_version: str | None = None, min_ssh_version: str | None = None,
win_agent_update_script: str | None = None, win_agent_update_script: str | None = None,
rdp_git_repo_url: str | None = None,
ssh_git_repo_url: str | None = None,
git_branch: str | None = None,
) -> AgentUpdateConfig: ) -> AgentUpdateConfig:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID) row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None: if row is None:
@@ -156,6 +171,21 @@ def upsert_agent_update_settings(
row.agent_min_ssh_version = min_ssh_version.strip() or None row.agent_min_ssh_version = min_ssh_version.strip() or None
if win_agent_update_script is not None: if win_agent_update_script is not None:
row.win_agent_update_script = win_agent_update_script.strip() or None row.win_agent_update_script = win_agent_update_script.strip() or None
if rdp_git_repo_url is not None:
row.agent_rdp_git_repo_url = rdp_git_repo_url.strip() or None
row.agent_git_rdp_version = None
row.agent_git_fetched_at = None
if ssh_git_repo_url is not None:
row.agent_ssh_git_repo_url = ssh_git_repo_url.strip() or None
row.agent_git_ssh_version = None
row.agent_git_fetched_at = None
if git_branch is not None:
branch = git_branch.strip() or "main"
if branch != (row.agent_git_branch or "main"):
row.agent_git_rdp_version = None
row.agent_git_ssh_version = None
row.agent_git_fetched_at = None
row.agent_git_branch = branch
db.commit() db.commit()
db.refresh(row) db.refresh(row)
+69
View File
@@ -60,6 +60,75 @@ def is_agent_version_outdated(
return lag >= lag_threshold return lag >= lag_threshold
def effective_reference_version(
*,
recommended: str = "",
fleet_latest: str = "",
min_version: str = "",
) -> str | None:
"""Единый эталон: max(recommended, min_version, max версия в fleet по продукту)."""
best: ParsedAgentVersion | None = None
best_raw: str | None = None
for raw in (recommended, min_version, fleet_latest):
text = (raw or "").strip()
parsed = parse_agent_version(text)
if parsed is None:
continue
if best is None or parsed > best:
best = parsed
best_raw = text
return best_raw
def reference_agent_versions_by_product(
cfg,
latest_map: dict[str, str],
*,
git_versions: dict[str, str] | None = None,
products: tuple[str, ...] = ("rdp-login-monitor", "ssh-monitor"),
) -> dict[str, str]:
from app.services.agent_git_release import recommended_from_git
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
git_versions = git_versions or {}
product_cfg = {
PRODUCT_RDP: (recommended_from_git(cfg, git_versions, PRODUCT_RDP), cfg.min_for_product(PRODUCT_RDP)),
PRODUCT_SSH: (recommended_from_git(cfg, git_versions, PRODUCT_SSH), cfg.min_for_product(PRODUCT_SSH)),
}
out: dict[str, str] = {}
for product in products:
recommended, min_version = product_cfg.get(product, ("", ""))
ref = effective_reference_version(
recommended=recommended,
fleet_latest=latest_map.get(product, ""),
min_version=min_version,
)
if ref:
out[product] = ref
return out
def host_version_outdated(
host_version: str | None,
*,
recommended: str = "",
fleet_latest: str = "",
min_version: str = "",
lag_threshold: int = _DEFAULT_LAG_THRESHOLD,
) -> bool | None:
"""True=outdated, False=ok, None=unknown (нет эталона или непарсится версия хоста)."""
if not host_version or parse_agent_version(host_version) is None:
return None
reference = effective_reference_version(
recommended=recommended,
fleet_latest=fleet_latest,
min_version=min_version,
)
if not reference:
return None
return is_agent_version_outdated(host_version, reference, lag_threshold=lag_threshold)
def latest_agent_versions_by_product(db: Session) -> dict[str, str]: def latest_agent_versions_by_product(db: Session) -> dict[str, str]:
rows = db.execute( rows = db.execute(
select(Host.product, Host.product_version).where( select(Host.product, Host.product_version).where(
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI).""" """Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center" APP_NAME = "Security Alert Center"
APP_VERSION = "0.12.1" APP_VERSION = "0.20.0"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+24
View File
@@ -80,6 +80,30 @@ def db_session(db_engine):
session.close() session.close()
@pytest.fixture(autouse=True)
def mock_agent_git_release(monkeypatch):
"""API tests must not clone real git repos."""
from app.services.agent_git_release import GitReleaseVersions
empty = GitReleaseVersions(versions={}, fetched_at=None, from_cache=True)
monkeypatch.setattr(
"app.services.agent_git_release.get_git_release_versions",
lambda cfg, **kwargs: empty,
)
monkeypatch.setattr(
"app.api.v1.hosts.get_git_release_versions",
lambda cfg, **kwargs: empty,
)
monkeypatch.setattr(
"app.api.v1.settings.get_git_release_versions",
lambda cfg, **kwargs: empty,
)
monkeypatch.setattr(
"app.services.agent_update.get_git_release_versions",
lambda cfg, **kwargs: empty,
)
@pytest.fixture @pytest.fixture
def client(db_session, monkeypatch): def client(db_session, monkeypatch):
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None) monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
+86
View File
@@ -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"
+62 -1
View File
@@ -1,6 +1,12 @@
"""Agent version parse/compare for outdated highlighting.""" """Agent version parse/compare for outdated highlighting."""
from app.services.agent_version import is_agent_version_outdated, latest_agent_versions_by_product, parse_agent_version from app.services.agent_version import (
effective_reference_version,
host_version_outdated,
is_agent_version_outdated,
latest_agent_versions_by_product,
parse_agent_version,
)
from app.models import Host from app.models import Host
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -27,6 +33,61 @@ def test_outdated_different_minor_or_major():
assert is_agent_version_outdated("2.1.0-SAC", latest) is False assert is_agent_version_outdated("2.1.0-SAC", latest) is False
def test_effective_reference_version_picks_max():
assert (
effective_reference_version(
recommended="2.1.0-SAC",
fleet_latest="2.0.6-SAC",
min_version="",
)
== "2.1.0-SAC"
)
assert (
effective_reference_version(
recommended="",
fleet_latest="2.0.6-SAC",
min_version="2.1.0-SAC",
)
== "2.1.0-SAC"
)
assert (
effective_reference_version(
recommended="",
fleet_latest="2.0.35-SAC",
min_version="",
)
== "2.0.35-SAC"
)
def test_host_version_outdated_ssh_vs_git():
assert (
host_version_outdated(
"2.0.6-SAC",
recommended="2.1.0-SAC",
fleet_latest="2.0.6-SAC",
)
is True
)
assert (
host_version_outdated(
"2.0.6-SAC",
recommended="",
fleet_latest="2.0.6-SAC",
)
is False
)
assert (
host_version_outdated(
"2.0.6-SAC",
recommended="",
fleet_latest="2.0.6-SAC",
min_version="2.1.0-SAC",
)
is True
)
def test_latest_agent_versions_by_product(db_session): def test_latest_agent_versions_by_product(db_session):
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
db_session.add_all( db_session.add_all(
+2 -2
View File
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants(): def test_version_constants():
assert APP_VERSION == "0.12.1" assert APP_VERSION == "0.20.0"
assert APP_NAME == "Security Alert Center" assert APP_NAME == "Security Alert Center"
assert APP_VERSION_LABEL == "Security Alert Center v.0.12.1" assert APP_VERSION_LABEL == "Security Alert Center v.0.20.0"
+23
View File
@@ -267,6 +267,8 @@ export interface HostListResponse {
page: number; page: number;
page_size: number; page_size: number;
latest_agent_versions?: Record<string, string>; latest_agent_versions?: Record<string, string>;
/** Эталон для «устарела»: max(git latest, min, max в fleet) */
reference_agent_versions?: Record<string, string>;
} }
export interface ProblemSummary { export interface ProblemSummary {
@@ -548,9 +550,24 @@ export interface AgentUpdateSettings {
min_rdp_version: string | null; min_rdp_version: string | null;
min_ssh_version: string | null; min_ssh_version: string | null;
win_agent_update_script: string | null; win_agent_update_script: string | null;
rdp_git_repo_url: string | null;
ssh_git_repo_url: string | null;
git_branch: string;
git_latest_rdp_version: string | null;
git_latest_ssh_version: string | null;
git_fetched_at: string | null;
git_errors: Record<string, string>;
source: string; source: string;
} }
export interface AgentGitTestResult {
git_latest_rdp_version: string | null;
git_latest_ssh_version: string | null;
git_fetched_at: string | null;
git_errors: Record<string, string>;
from_cache: boolean;
}
export function fetchAgentUpdateSettings(): Promise<AgentUpdateSettings> { export function fetchAgentUpdateSettings(): Promise<AgentUpdateSettings> {
return apiFetch<AgentUpdateSettings>("/api/v1/settings/agent-updates"); return apiFetch<AgentUpdateSettings>("/api/v1/settings/agent-updates");
} }
@@ -564,6 +581,12 @@ export function saveAgentUpdateSettings(
}); });
} }
export function testAgentGitRelease(): Promise<AgentGitTestResult> {
return apiFetch<AgentGitTestResult>("/api/v1/settings/agent-updates/test-git", {
method: "POST",
});
}
export interface DashboardSummary { export interface DashboardSummary {
events_last_24h: number; events_last_24h: number;
hosts_total: number; hosts_total: number;
+3
View File
@@ -13,6 +13,9 @@ export function patchHostSummaryFromDetail(target: HostSummary, detail: HostDeta
target.last_daily_report_at = detail.last_daily_report_at; target.last_daily_report_at = detail.last_daily_report_at;
target.last_inventory_at = detail.last_inventory_at; target.last_inventory_at = detail.last_inventory_at;
target.agent_status = detail.agent_status; target.agent_status = detail.agent_status;
if (detail.version_status) {
target.version_status = detail.version_status;
}
} }
export function bumpLatestAgentVersion( export function bumpLatestAgentVersion(
+1 -1
View File
@@ -1,4 +1,4 @@
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
export const APP_NAME = "Security Alert Center"; export const APP_NAME = "Security Alert Center";
export const APP_VERSION = "0.12.1"; export const APP_VERSION = "0.20.0";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+13 -6
View File
@@ -202,16 +202,23 @@ function agentLabel(status: string) {
function isVersionOutdated(h: HostSummary): boolean { function isVersionOutdated(h: HostSummary): boolean {
if (h.version_status === "outdated") return true; if (h.version_status === "outdated") return true;
if (h.version_status === "ok") return false; if (h.version_status === "ok") return false;
const latest = data.value?.latest_agent_versions?.[h.product]; const reference =
return isAgentVersionOutdated(h.product_version, latest); data.value?.reference_agent_versions?.[h.product] ??
data.value?.latest_agent_versions?.[h.product];
return isAgentVersionOutdated(h.product_version, reference);
} }
function versionTitle(h: HostSummary): string { function versionTitle(h: HostSummary): string {
const latest = data.value?.latest_agent_versions?.[h.product]; const reference =
data.value?.reference_agent_versions?.[h.product] ??
data.value?.latest_agent_versions?.[h.product];
if (!isVersionOutdated(h)) return ""; if (!isVersionOutdated(h)) return "";
return latest if (!reference) return "Устаревшая версия агента";
? `Устарела относительно последней для ${h.product}: ${latest}` const fromSettings = data.value?.reference_agent_versions?.[h.product];
: "Устаревшая версия агента"; if (fromSettings && fromSettings !== data.value?.latest_agent_versions?.[h.product]) {
return `Устарела относительно эталона ${reference} (настройки SAC)`;
}
return `Устарела относительно эталона ${reference}`;
} }
function openHost(id: number) { function openHost(id: number) {
+78 -10
View File
@@ -126,6 +126,8 @@
Режим <code>gpo</code> обновления вручную/GPO; SAC только показывает устаревшие версии. Режим <code>gpo</code> обновления вручную/GPO; SAC только показывает устаревшие версии.
Режим <code>sac</code> «Запросить обновление» ставит флаг в poll; агент self-update; Режим <code>sac</code> «Запросить обновление» ставит флаг в poll; агент self-update;
через {{ agentUpdateForm.fallback_after_minutes }} мин без ответа fallback SSH/WinRM с SAC. через {{ agentUpdateForm.fallback_after_minutes }} мин без ответа fallback SSH/WinRM с SAC.
<strong>Эталон «устарела»</strong> берётся из <code>version.txt</code> / скриптов в git-репозиториях
(ветка <code>main</code> по умолчанию); при недоступности git fallback на max версию в fleet.
</p> </p>
<form class="settings-form" @submit.prevent="saveAgentUpdateSettingsForm"> <form class="settings-form" @submit.prevent="saveAgentUpdateSettingsForm">
<label class="settings-field"> <label class="settings-field">
@@ -144,13 +146,34 @@
<input v-model.number="agentUpdateForm.fallback_after_minutes" type="number" min="1" max="1440" /> <input v-model.number="agentUpdateForm.fallback_after_minutes" type="number" min="1" max="1440" />
</label> </label>
<label class="settings-field"> <label class="settings-field">
Recommended RDP-login-monitor Git: RDP-login-monitor
<input v-model="agentUpdateForm.recommended_rdp_version" type="text" placeholder="2.1.0-SAC" /> <input
v-model="agentUpdateForm.rdp_git_repo_url"
type="text"
placeholder="https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
/>
</label> </label>
<label class="settings-field"> <label class="settings-field">
Recommended ssh-monitor Git: ssh-monitor
<input v-model="agentUpdateForm.recommended_ssh_version" type="text" placeholder="2.1.0-SAC" /> <input
v-model="agentUpdateForm.ssh_git_repo_url"
type="text"
placeholder="https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
/>
</label> </label>
<label class="settings-field">
Git-ветка
<input v-model="agentUpdateForm.git_branch" type="text" placeholder="main" />
</label>
<p v-if="agentUpdateLoaded?.git_latest_rdp_version || agentUpdateLoaded?.git_latest_ssh_version" class="settings-meta">
Latest из git:
RDP <code>{{ agentUpdateLoaded?.git_latest_rdp_version ?? "—" }}</code>,
SSH <code>{{ agentUpdateLoaded?.git_latest_ssh_version ?? "—" }}</code>
<span v-if="agentUpdateLoaded?.git_fetched_at">
· обновлено {{ formatDateTime(agentUpdateLoaded.git_fetched_at) }}
</span>
</p>
<p v-if="agentGitTestErrors" class="settings-error-inline">{{ agentGitTestErrors }}</p>
<label class="settings-field"> <label class="settings-field">
WinRM: путь к Deploy-LoginMonitor.ps1 (fallback) WinRM: путь к Deploy-LoginMonitor.ps1 (fallback)
<input <input
@@ -163,6 +186,9 @@
Источник: <code>{{ agentUpdateLoaded.source }}</code> Источник: <code>{{ agentUpdateLoaded.source }}</code>
</p> </p>
<div class="settings-actions"> <div class="settings-actions">
<button type="button" :disabled="testingAgentGit" @click="runAgentGitTest">
{{ testingAgentGit ? "Проверка git" : "Проверить git" }}
</button>
<button type="submit" :disabled="savingAgentUpdate"> <button type="submit" :disabled="savingAgentUpdate">
{{ savingAgentUpdate ? "Сохранение…" : "Сохранить обновления агентов" }} {{ savingAgentUpdate ? "Сохранение…" : "Сохранить обновления агентов" }}
</button> </button>
@@ -566,6 +592,7 @@ import {
fetchLinuxAdminSettings, fetchLinuxAdminSettings,
fetchAgentUpdateSettings, fetchAgentUpdateSettings,
saveAgentUpdateSettings, saveAgentUpdateSettings,
testAgentGitRelease,
revokeMobileDevice, revokeMobileDevice,
deleteMobileDevice, deleteMobileDevice,
revokeMobileEnrollmentCode, revokeMobileEnrollmentCode,
@@ -658,6 +685,8 @@ const savingUi = ref(false);
const savingWinAdmin = ref(false); const savingWinAdmin = ref(false);
const savingLinuxAdmin = ref(false); const savingLinuxAdmin = ref(false);
const savingAgentUpdate = ref(false); const savingAgentUpdate = ref(false);
const testingAgentGit = ref(false);
const agentGitTestErrors = ref("");
const savingTelegram = ref(false); const savingTelegram = ref(false);
const savingWebhook = ref(false); const savingWebhook = ref(false);
const savingEmail = ref(false); const savingEmail = ref(false);
@@ -754,8 +783,9 @@ const agentUpdateForm = reactive({
mode: "gpo" as "gpo" | "sac", mode: "gpo" as "gpo" | "sac",
fallback_enabled: true, fallback_enabled: true,
fallback_after_minutes: 15, fallback_after_minutes: 15,
recommended_rdp_version: "", rdp_git_repo_url: "",
recommended_ssh_version: "", ssh_git_repo_url: "",
git_branch: "main",
win_agent_update_script: "", win_agent_update_script: "",
}); });
@@ -973,15 +1003,51 @@ async function saveLinuxAdminSettings() {
} }
} }
function formatDateTime(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("ru-RU");
}
async function loadAgentUpdateSettings() { async function loadAgentUpdateSettings() {
const data = await fetchAgentUpdateSettings(); const data = await fetchAgentUpdateSettings();
agentUpdateLoaded.value = data; agentUpdateLoaded.value = data;
agentUpdateForm.mode = data.mode; agentUpdateForm.mode = data.mode;
agentUpdateForm.fallback_enabled = data.fallback_enabled; agentUpdateForm.fallback_enabled = data.fallback_enabled;
agentUpdateForm.fallback_after_minutes = data.fallback_after_minutes; agentUpdateForm.fallback_after_minutes = data.fallback_after_minutes;
agentUpdateForm.recommended_rdp_version = data.recommended_rdp_version ?? ""; agentUpdateForm.rdp_git_repo_url = data.rdp_git_repo_url ?? "";
agentUpdateForm.recommended_ssh_version = data.recommended_ssh_version ?? ""; agentUpdateForm.ssh_git_repo_url = data.ssh_git_repo_url ?? "";
agentUpdateForm.git_branch = data.git_branch ?? "main";
agentUpdateForm.win_agent_update_script = data.win_agent_update_script ?? ""; agentUpdateForm.win_agent_update_script = data.win_agent_update_script ?? "";
agentGitTestErrors.value = Object.values(data.git_errors ?? {}).join("; ");
}
async function runAgentGitTest() {
testingAgentGit.value = true;
agentGitTestErrors.value = "";
error.value = "";
try {
const result = await testAgentGitRelease();
if (agentUpdateLoaded.value) {
agentUpdateLoaded.value = {
...agentUpdateLoaded.value,
git_latest_rdp_version: result.git_latest_rdp_version,
git_latest_ssh_version: result.git_latest_ssh_version,
git_fetched_at: result.git_fetched_at,
git_errors: result.git_errors,
};
}
const errs = Object.values(result.git_errors ?? {});
agentGitTestErrors.value = errs.length ? errs.join("; ") : "";
success.value = result.from_cache
? "Версии из кэша (git не менялся)."
: "Версии успешно прочитаны из git.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка проверки git";
} finally {
testingAgentGit.value = false;
}
} }
async function saveAgentUpdateSettingsForm() { async function saveAgentUpdateSettingsForm() {
@@ -993,10 +1059,12 @@ async function saveAgentUpdateSettingsForm() {
mode: agentUpdateForm.mode, mode: agentUpdateForm.mode,
fallback_enabled: agentUpdateForm.fallback_enabled, fallback_enabled: agentUpdateForm.fallback_enabled,
fallback_after_minutes: agentUpdateForm.fallback_after_minutes, fallback_after_minutes: agentUpdateForm.fallback_after_minutes,
recommended_rdp_version: agentUpdateForm.recommended_rdp_version.trim() || null, rdp_git_repo_url: agentUpdateForm.rdp_git_repo_url.trim() || null,
recommended_ssh_version: agentUpdateForm.recommended_ssh_version.trim() || null, ssh_git_repo_url: agentUpdateForm.ssh_git_repo_url.trim() || null,
git_branch: agentUpdateForm.git_branch.trim() || "main",
win_agent_update_script: agentUpdateForm.win_agent_update_script.trim() || null, win_agent_update_script: agentUpdateForm.win_agent_update_script.trim() || null,
}); });
agentGitTestErrors.value = Object.values(agentUpdateLoaded.value.git_errors ?? {}).join("; ");
success.value = "Настройки обновления агентов сохранены."; success.value = "Настройки обновления агентов сохранены.";
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения agent-updates"; error.value = e instanceof Error ? e.message : "Ошибка сохранения agent-updates";
+30
View File
@@ -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)
+12
View File
@@ -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