From c21043d845a3a20490ae3168c60af41e8d5d4083 Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 17:01:06 +1000 Subject: [PATCH] feat: bootstrap ssh-monitor updater when missing on host (0.20.2) SAC now clones the ssh-monitor repo and installs update_ssh_monitor.sh before running the update when the remote updater script is absent. --- backend/app/api/v1/hosts.py | 9 +++- backend/app/services/agent_update.py | 19 +++++-- backend/app/services/ssh_connect.py | 75 +++++++++++++++++++++++----- backend/app/version.py | 2 +- backend/tests/test_ssh_connect.py | 67 +++++++++++++++++++++---- 5 files changed, 146 insertions(+), 26 deletions(-) diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index a36bfb7..87303c9 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -4,6 +4,7 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session from datetime import datetime, timezone +from functools import partial from app.auth.jwt_auth import get_current_user, require_admin from app.config import get_settings @@ -412,7 +413,13 @@ def update_host_agent_via_ssh( if not cfg.configured: raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") - response = _run_ssh_action_on_host(host, cfg, action=run_ssh_monitor_update) + agent_cfg = get_effective_agent_update_config(db) + repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None + response = _run_ssh_action_on_host( + host, + cfg, + action=partial(run_ssh_monitor_update, repo_url=repo_url), + ) _set_ssh_admin_status(host, response.ok) if response.ok and response.product_version: host.product_version = response.product_version diff --git a/backend/app/services/agent_update.py b/backend/app/services/agent_update.py index 34301b8..7c7b870 100644 --- a/backend/app/services/agent_update.py +++ b/backend/app/services/agent_update.py @@ -141,7 +141,12 @@ def process_agent_update_ingest(db: Session, host: Host, event_type: str, detail _clear_pending(host) -def run_linux_agent_update_fallback(host: Host, cfg_linux) -> tuple[bool, str, str | None]: +def run_linux_agent_update_fallback( + host: Host, + cfg_linux, + *, + repo_url: str | None = None, +) -> tuple[bool, str, str | None]: if not is_linux_host(host): return False, "Host is not Linux", None if not cfg_linux.configured: @@ -154,8 +159,14 @@ def run_linux_agent_update_fallback(host: Host, cfg_linux) -> tuple[bool, str, s except (HostNotLinuxError, HostTargetMissingError) as exc: return False, str(exc), None + effective_repo = (repo_url or "").strip() or None for target in targets: - result = run_ssh_monitor_update(target=target, user=cfg_linux.user, password=cfg_linux.password) + result = run_ssh_monitor_update( + target=target, + user=cfg_linux.user, + password=cfg_linux.password, + repo_url=effective_repo, + ) last_message = result.message if result.ok: version = result.agent_version @@ -204,7 +215,9 @@ def execute_agent_update_fallback(db: Session, host: Host) -> tuple[bool, str, s if product == PRODUCT_SSH or is_linux_host(host): linux_cfg = get_effective_linux_admin_config(db) - ok, message, version = run_linux_agent_update_fallback(host, linux_cfg) + agent_cfg = get_effective_agent_update_config(db) + repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None + ok, message, version = run_linux_agent_update_fallback(host, linux_cfg, repo_url=repo_url) elif product == PRODUCT_RDP or is_windows_host(host): win_cfg = get_effective_win_admin_config(db) ok, message, version = run_windows_agent_update_fallback(host, win_cfg, cfg.win_agent_update_script) diff --git a/backend/app/services/ssh_connect.py b/backend/app/services/ssh_connect.py index 373e655..2c41f3a 100644 --- a/backend/app/services/ssh_connect.py +++ b/backend/app/services/ssh_connect.py @@ -9,6 +9,9 @@ from dataclasses import dataclass from app.models import Host SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh" +SSH_MONITOR_UPDATE_DIR = "/opt/scripts/update" +SSH_MONITOR_REPO_NAME = "ssh-monitor" +SSH_MONITOR_UPDATE_REPO_URL = "https://git.kalinamall.ru/PapaTramp/ssh-monitor.git" SSH_MONITOR_BINARY = "/usr/local/bin/ssh-monitor" SSH_MONITOR_VERSION_CMD = ( f"grep -m1 '^SSH_MONITOR_VERSION=' {SSH_MONITOR_BINARY} 2>/dev/null " @@ -271,13 +274,30 @@ def probe_ssh_monitor_version( return None +def _ssh_monitor_bootstrap_command(repo_url: str) -> str: + """Clone ssh-monitor repo and install updater when /opt/scripts/update_ssh_monitor.sh is absent.""" + safe_repo = repo_url.replace("\\", "\\\\").replace('"', '\\"') + return ( + f'UPDATE_DIR="{SSH_MONITOR_UPDATE_DIR}";' + f'REPO_URL="{safe_repo}";' + f'SCRIPT_NAME="{SSH_MONITOR_REPO_NAME}";' + f'INSTALL="{SSH_MONITOR_UPDATE_SCRIPT}";' + f'mkdir -p "$UPDATE_DIR" && cd "$UPDATE_DIR" && ' + f'([ -d "$SCRIPT_NAME" ] || git clone "$REPO_URL" "$SCRIPT_NAME") && ' + f'cp "$UPDATE_DIR/$SCRIPT_NAME/update_ssh_monitor.sh" "$INSTALL" && ' + f'chmod 750 "$INSTALL" && "$INSTALL"' + ) + + def run_ssh_monitor_update( *, target: str, user: str, password: str, + repo_url: str | None = None, ) -> SshCommandResult: script = SSH_MONITOR_UPDATE_SCRIPT + effective_repo = (repo_url or SSH_MONITOR_UPDATE_REPO_URL).strip() check_cmd = f"test -x {script} && echo ready || echo missing" probe = run_ssh_command( target=target, @@ -286,32 +306,54 @@ def run_ssh_monitor_update( remote_cmd=check_cmd, command_timeout_sec=30, ) - if not probe.ok or "missing" in probe.stdout: + if not probe.ok: return SshCommandResult( ok=False, - message=f"Update script not found or not executable: {script} on {target}", + message=f"SSH probe failed on {target}: {probe.message}", target=target, stdout=probe.stdout, stderr=probe.stderr, exit_code=probe.exit_code, ) - updated = run_ssh_command( - target=target, - user=user, - password=password, - remote_cmd=script, - command_timeout_sec=900, - need_root=True, - ) + bootstrapped = False + if "missing" in probe.stdout: + bootstrap_cmd = _ssh_monitor_bootstrap_command(effective_repo) + updated = run_ssh_command( + target=target, + user=user, + password=password, + remote_cmd=bootstrap_cmd, + command_timeout_sec=900, + need_root=True, + ) + bootstrapped = True + else: + updated = run_ssh_command( + target=target, + user=user, + password=password, + remote_cmd=script, + command_timeout_sec=900, + need_root=True, + ) if not updated.ok: - return updated + prefix = "Updater bootstrap failed" if bootstrapped else "SSH update failed" + return SshCommandResult( + ok=False, + message=f"{prefix} ({target}): {updated.message}", + target=updated.target, + stdout=updated.stdout, + stderr=updated.stderr, + exit_code=updated.exit_code, + ) version = probe_ssh_monitor_version(target=target, user=user, password=password) if not version: version = parse_ssh_monitor_version_text(updated.stdout) if version: - message = f"{updated.message}\nagent version: {version}" + prefix = "Bootstrap + update OK" if bootstrapped else updated.message + message = f"{prefix}\nagent version: {version}" return SshCommandResult( ok=True, message=message, @@ -321,4 +363,13 @@ def run_ssh_monitor_update( exit_code=updated.exit_code, agent_version=version, ) + if bootstrapped: + return SshCommandResult( + ok=True, + message=f"Bootstrap + update OK ({target}), exit 0", + target=updated.target, + stdout=updated.stdout, + stderr=updated.stderr, + exit_code=updated.exit_code, + ) return updated diff --git a/backend/app/version.py b/backend/app/version.py index 9f678d5..eeb9dd6 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.20.1" +APP_VERSION = "0.20.2" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_ssh_connect.py b/backend/tests/test_ssh_connect.py index eb027cd..6858128 100644 --- a/backend/tests/test_ssh_connect.py +++ b/backend/tests/test_ssh_connect.py @@ -152,21 +152,70 @@ def test_run_ssh_command_auth_failure(monkeypatch): client.close.assert_called_once() -def test_run_ssh_monitor_update_missing_script(monkeypatch): - monkeypatch.setattr( - ssh_connect, - "run_ssh_command", - lambda **kwargs: ssh_connect.SshCommandResult( +def test_run_ssh_monitor_update_missing_script_bootstraps(monkeypatch): + calls = 0 + + def fake_run(**kwargs): + nonlocal calls + calls += 1 + if calls == 1: + return ssh_connect.SshCommandResult( + ok=True, + message="ok", + target="h", + stdout="missing\n", + ) + if calls == 2: + cmd = kwargs["remote_cmd"] + assert "git clone" in cmd + assert "update_ssh_monitor.sh" in cmd + assert kwargs.get("need_root") is True + return ssh_connect.SshCommandResult( + ok=True, + message="SSH OK", + target="185.87.149.9", + stdout="SUMMARY updated 2.1.0-SAC\n", + exit_code=0, + ) + return ssh_connect.SshCommandResult( ok=True, message="ok", + target="185.87.149.9", + stdout="2.1.0-SAC\n", + exit_code=0, + ) + + monkeypatch.setattr(ssh_connect, "run_ssh_command", fake_run) + + result = run_ssh_monitor_update(target="185.87.149.9", user="root", password="pw") + assert result.ok is True + assert result.agent_version == "2.1.0-SAC" + assert "Bootstrap" in result.message + assert calls == 3 + + +def test_run_ssh_monitor_update_missing_script(monkeypatch): + def fake_run(**kwargs): + if kwargs["remote_cmd"].startswith("test -x"): + return ssh_connect.SshCommandResult( + ok=True, + message="ok", + target="h", + stdout="missing\n", + ) + return ssh_connect.SshCommandResult( + ok=False, + message="git clone failed", target="h", - stdout="missing\n", - ), - ) + stderr="fatal: could not read", + exit_code=128, + ) + + monkeypatch.setattr(ssh_connect, "run_ssh_command", fake_run) result = run_ssh_monitor_update(target="ubabuba", user="root", password="pw") assert result.ok is False - assert "not found" in result.message.lower() + assert "bootstrap failed" in result.message.lower() def test_run_ssh_monitor_update_runs_script(monkeypatch):