From cc8f50de898a4a04509aeb3904cd7b0c51c2539d Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 00:25:35 +1000 Subject: [PATCH] feat: Linux SSH admin and remote ssh-monitor update (0.11.0) --- .../versions/018_ui_settings_linux_admin.py | 25 +++ backend/app/api/v1/hosts.py | 98 ++++++++ backend/app/api/v1/settings.py | 47 ++++ backend/app/config.py | 4 + backend/app/models/ui_settings.py | 2 + backend/app/services/linux_admin_settings.py | 82 +++++++ backend/app/services/ssh_connect.py | 212 ++++++++++++++++++ backend/app/version.py | 2 +- backend/requirements.txt | 1 + backend/tests/test_health.py | 4 +- backend/tests/test_linux_admin_settings.py | 79 +++++++ deploy/env.native.example | 4 + frontend/package.json | 2 +- frontend/src/api.ts | 42 ++++ frontend/src/version.ts | 2 +- frontend/src/views/HostDetailView.vue | 106 +++++++++ frontend/src/views/SettingsView.vue | 98 ++++++++ 17 files changed, 805 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/018_ui_settings_linux_admin.py create mode 100644 backend/app/services/linux_admin_settings.py create mode 100644 backend/app/services/ssh_connect.py create mode 100644 backend/tests/test_linux_admin_settings.py diff --git a/backend/alembic/versions/018_ui_settings_linux_admin.py b/backend/alembic/versions/018_ui_settings_linux_admin.py new file mode 100644 index 0000000..362d572 --- /dev/null +++ b/backend/alembic/versions/018_ui_settings_linux_admin.py @@ -0,0 +1,25 @@ +"""ui_settings: Linux SSH admin for remote agent updates + +Revision ID: 018 +Revises: 017 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "018" +down_revision: Union[str, None] = "017" +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("linux_admin_user", sa.String(128), nullable=True)) + op.add_column("ui_settings", sa.Column("linux_admin_password", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("ui_settings", "linux_admin_password") + op.drop_column("ui_settings", "linux_admin_user") diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index 69343d6..2820171 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -10,6 +10,7 @@ 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.host_delete import delete_host_and_related +from app.services.linux_admin_settings import get_effective_linux_admin_config from app.services.win_admin_settings import get_effective_win_admin_config from app.services.winrm_connect import ( HostNotWindowsError, @@ -19,6 +20,15 @@ from app.services.winrm_connect import ( iter_winrm_targets, test_winrm_connection, ) +from app.services.ssh_connect import ( + HostNotLinuxError as SshHostNotLinuxError, + HostTargetMissingError as SshHostTargetMissingError, + LinuxAdminNotConfiguredError, + SshCommandResult, + iter_ssh_targets, + run_ssh_monitor_update, + test_ssh_connection, +) from app.services.host_health import ( HEARTBEAT_TYPE, agent_status as compute_agent_status, @@ -172,6 +182,60 @@ class HostWinRmTestResponse(BaseModel): hostname: str | None = None +class HostSshActionResponse(BaseModel): + ok: bool + message: str + target: str + stdout: str | None = None + stderr: str | None = None + exit_code: int | None = None + + +def _ssh_action_response(result: SshCommandResult, *, attempts: list[str] | None = None) -> HostSshActionResponse: + message = result.message + if attempts: + message = f"{message} (пробовали: {', '.join(attempts)})" + return HostSshActionResponse( + ok=result.ok, + message=message, + target=result.target, + stdout=result.stdout or None, + stderr=result.stderr or None, + exit_code=result.exit_code, + ) + + +def _run_ssh_action_on_host( + host: Host, + cfg, + *, + action, +) -> HostSshActionResponse: + try: + targets = iter_ssh_targets(host) + except SshHostNotLinuxError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except SshHostTargetMissingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + last_result: SshCommandResult | None = None + attempts: list[str] = [] + for target in targets: + try: + result = action(target=target, user=cfg.user, password=cfg.password) + except LinuxAdminNotConfiguredError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + last_result = result + attempts.append(f"{target}={'OK' if result.ok else 'fail'}") + if result.ok: + return _ssh_action_response(result, attempts=attempts if len(attempts) > 1 else None) + + assert last_result is not None + return _ssh_action_response(last_result, attempts=attempts if len(attempts) > 1 else None) + + @router.post("/{host_id}/actions/winrm-test", response_model=HostWinRmTestResponse) def test_host_winrm( host_id: int, @@ -235,6 +299,40 @@ def test_host_winrm( ) +@router.post("/{host_id}/actions/ssh-test", response_model=HostSshActionResponse) +def test_host_ssh( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostSshActionResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + + cfg = get_effective_linux_admin_config(db) + if not cfg.configured: + raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") + + return _run_ssh_action_on_host(host, cfg, action=test_ssh_connection) + + +@router.post("/{host_id}/actions/agent-update", response_model=HostSshActionResponse) +def update_host_agent_via_ssh( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostSshActionResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + + cfg = get_effective_linux_admin_config(db) + if not cfg.configured: + raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") + + return _run_ssh_action_on_host(host, cfg, action=run_ssh_monitor_update) + + @router.delete("/{host_id}", response_model=HostDeleteResponse) def delete_host( host_id: int, diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 52c9a79..2cf8ae7 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -37,6 +37,10 @@ from app.services.ui_settings import ( get_effective_ui_settings, upsert_ui_settings, ) +from app.services.linux_admin_settings import ( + get_effective_linux_admin_config, + upsert_linux_admin_settings, +) from app.services.win_admin_settings import ( get_effective_win_admin_config, upsert_win_admin_settings, @@ -493,3 +497,46 @@ def update_win_admin_settings( password_hint=_mask_secret(cfg.password), source=cfg.source, ) + + +class LinuxAdminSettingsResponse(BaseModel): + configured: bool + user: str | None = None + password_hint: str | None = None + source: str = Field(description="env или db") + + +class LinuxAdminSettingsUpdate(BaseModel): + user: str | None = None + password: str | None = None + + +@router.get("/linux-admin", response_model=LinuxAdminSettingsResponse) +def get_linux_admin_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> LinuxAdminSettingsResponse: + cfg = get_effective_linux_admin_config(db) + return LinuxAdminSettingsResponse( + configured=cfg.configured, + user=cfg.user or None, + password_hint=_mask_secret(cfg.password), + source=cfg.source, + ) + + +@router.put("/linux-admin", response_model=LinuxAdminSettingsResponse) +def update_linux_admin_settings( + body: LinuxAdminSettingsUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> LinuxAdminSettingsResponse: + if body.user is not None and not body.user.strip(): + raise HTTPException(status_code=422, detail="user must not be empty") + cfg = upsert_linux_admin_settings(db, user=body.user, password=body.password) + return LinuxAdminSettingsResponse( + configured=cfg.configured, + user=cfg.user or None, + password_hint=_mask_secret(cfg.password), + source=cfg.source, + ) diff --git a/backend/app/config.py b/backend/app/config.py index 290d04d..5d022dc 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -111,6 +111,10 @@ class Settings(BaseSettings): sac_win_admin_user: str = "" sac_win_admin_password: str = "" + # Linux SSH admin for remote agent update (fallback SSH, phase 5) + sac_linux_admin_user: str = "" + sac_linux_admin_password: str = "" + # Retention (app.jobs.retention / systemd timer) sac_events_retention_days: int = 90 sac_problems_retention_days: int = 180 diff --git a/backend/app/models/ui_settings.py b/backend/app/models/ui_settings.py index 65fe5df..c8fb3cb 100644 --- a/backend/app/models/ui_settings.py +++ b/backend/app/models/ui_settings.py @@ -15,3 +15,5 @@ class UiSettings(Base): show_sidebar_system_stats: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) win_admin_user: Mapped[str | None] = mapped_column(String(256), nullable=True) win_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True) + linux_admin_user: Mapped[str | None] = mapped_column(String(128), nullable=True) + linux_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/services/linux_admin_settings.py b/backend/app/services/linux_admin_settings.py new file mode 100644 index 0000000..f1306dd --- /dev/null +++ b/backend/app/services/linux_admin_settings.py @@ -0,0 +1,82 @@ +"""Effective Linux SSH admin credentials (DB overrides env).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings + + +@dataclass(frozen=True) +class LinuxAdminConfig: + user: str + password: str + source: str # env | db + + @property + def configured(self) -> bool: + return bool(self.user.strip() and self.password.strip()) + + +def _linux_admin_from_env() -> LinuxAdminConfig: + settings = get_settings() + return LinuxAdminConfig( + user=settings.sac_linux_admin_user.strip(), + password=settings.sac_linux_admin_password, + source="env", + ) + + +def get_effective_linux_admin_config(db: Session | None = None) -> LinuxAdminConfig: + if db is None: + from app.database import SessionLocal + + session = SessionLocal() + try: + return get_effective_linux_admin_config(session) + finally: + session.close() + + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + env_cfg = _linux_admin_from_env() + if row is None: + return env_cfg + + user = (row.linux_admin_user or "").strip() or env_cfg.user + password = (row.linux_admin_password or "") or env_cfg.password + has_db_values = bool((row.linux_admin_user or "").strip() or (row.linux_admin_password or "").strip()) + return LinuxAdminConfig( + user=user, + password=password, + source="db" if has_db_values else env_cfg.source, + ) + + +def upsert_linux_admin_settings( + db: Session, + *, + user: str | None = None, + password: str | None = None, +) -> LinuxAdminConfig: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + env_cfg = _linux_admin_from_env() + if row is None: + row = UiSettings( + id=UI_SETTINGS_ROW_ID, + show_sidebar_system_stats=True, + linux_admin_user=env_cfg.user or None, + linux_admin_password=env_cfg.password or None, + ) + db.add(row) + + if user is not None and user.strip(): + row.linux_admin_user = user.strip() + if password is not None and password.strip(): + row.linux_admin_password = password + + db.commit() + db.refresh(row) + return get_effective_linux_admin_config(db) diff --git a/backend/app/services/ssh_connect.py b/backend/app/services/ssh_connect.py new file mode 100644 index 0000000..40bd240 --- /dev/null +++ b/backend/app/services/ssh_connect.py @@ -0,0 +1,212 @@ +"""SSH connectivity and remote commands for Linux hosts.""" + +from __future__ import annotations + +import socket +from dataclasses import dataclass + +from app.models import Host + +SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh" +SSH_OUTPUT_MAX_LEN = 12_000 + + +class LinuxAdminNotConfiguredError(Exception): + pass + + +class HostNotLinuxError(Exception): + pass + + +class HostTargetMissingError(Exception): + pass + + +@dataclass(frozen=True) +class SshCommandResult: + ok: bool + message: str + target: str + stdout: str = "" + stderr: str = "" + exit_code: int | None = None + + +def is_linux_host(host: Host) -> bool: + if (host.os_family or "").strip().lower() == "linux": + return True + return (host.product or "").strip() == "ssh-monitor" + + +def iter_ssh_targets(host: Host) -> list[str]: + if not is_linux_host(host): + raise HostNotLinuxError("Host is not Linux") + + seen: set[str] = set() + ordered: list[str] = [] + + def add(value: str | None) -> None: + text = (value or "").strip() + if not text or text.casefold() in seen: + return + seen.add(text.casefold()) + ordered.append(text) + + add(host.hostname) + add(host.display_name) + add(host.ipv4) + if not ordered: + raise HostTargetMissingError("Host has no hostname or IPv4 for SSH") + return ordered + + +def _truncate_output(text: str) -> str: + if len(text) <= SSH_OUTPUT_MAX_LEN: + return text + return text[:SSH_OUTPUT_MAX_LEN] + "\n… (truncated)" + + +def _remote_shell_command(user: str, remote_cmd: str) -> str: + safe_cmd = remote_cmd.replace("'", "'\"'\"'") + if user == "root": + return f"bash -lc '{safe_cmd}'" + return f"bash -lc 'sudo -n {safe_cmd} 2>/dev/null || sudo {safe_cmd}'" + + +def run_ssh_command( + *, + target: str, + user: str, + password: str, + remote_cmd: str, + connect_timeout_sec: int = 15, + command_timeout_sec: int = 600, +) -> SshCommandResult: + target = target.strip() + user = user.strip() + if not target or not user or not password: + raise LinuxAdminNotConfiguredError("Linux SSH admin credentials or target missing") + + try: + import paramiko + except ImportError as exc: + raise RuntimeError("paramiko is not installed on SAC server") from exc + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + shell_cmd = _remote_shell_command(user, remote_cmd) + + try: + client.connect( + hostname=target, + username=user, + password=password, + timeout=connect_timeout_sec, + allow_agent=False, + look_for_keys=False, + ) + _stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec) + exit_code = stdout.channel.recv_exit_status() + out_text = _truncate_output(stdout.read().decode("utf-8", errors="replace")) + err_text = _truncate_output(stderr.read().decode("utf-8", errors="replace")) + except paramiko.AuthenticationException: + return SshCommandResult( + ok=False, + message=f"SSH auth failed for {user}@{target}", + target=target, + ) + except (socket.timeout, TimeoutError): + return SshCommandResult( + ok=False, + message=f"SSH timeout ({connect_timeout_sec}s connect / {command_timeout_sec}s cmd) to {target}", + target=target, + ) + except Exception as exc: + return SshCommandResult( + ok=False, + message=f"SSH error ({target}): {exc}", + target=target, + ) + finally: + client.close() + + ok = exit_code == 0 + if ok: + message = f"SSH OK ({target}), exit 0" + if out_text.strip(): + message = f"{message}\n{out_text.strip().splitlines()[0][:200]}" + else: + detail = err_text.strip() or out_text.strip() or f"exit code {exit_code}" + message = f"SSH command failed ({target}): {detail[:500]}" + + return SshCommandResult( + ok=ok, + message=message, + target=target, + stdout=out_text, + stderr=err_text, + exit_code=exit_code, + ) + + +def test_ssh_connection( + *, + target: str, + user: str, + password: str, + timeout_sec: int = 15, +) -> SshCommandResult: + result = run_ssh_command( + target=target, + user=user, + password=password, + remote_cmd="hostname", + connect_timeout_sec=timeout_sec, + command_timeout_sec=timeout_sec, + ) + if result.ok and result.stdout.strip(): + host = result.stdout.strip().splitlines()[0] + return SshCommandResult( + ok=True, + message=f"SSH OK, hostname={host}", + target=target, + stdout=result.stdout, + stderr=result.stderr, + exit_code=result.exit_code, + ) + return result + + +def run_ssh_monitor_update( + *, + target: str, + user: str, + password: str, +) -> SshCommandResult: + script = SSH_MONITOR_UPDATE_SCRIPT + check_cmd = f"test -x {script} && echo ready || echo missing" + probe = run_ssh_command( + target=target, + user=user, + password=password, + remote_cmd=check_cmd, + command_timeout_sec=30, + ) + if not probe.ok or "missing" in probe.stdout: + return SshCommandResult( + ok=False, + message=f"Update script not found or not executable: {script} on {target}", + target=target, + stdout=probe.stdout, + stderr=probe.stderr, + exit_code=probe.exit_code, + ) + + return run_ssh_command( + target=target, + user=user, + password=password, + remote_cmd=script, + command_timeout_sec=900, + ) diff --git a/backend/app/version.py b/backend/app/version.py index fac61a3..6044ca5 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.10.4" +APP_VERSION = "0.11.0" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/requirements.txt b/backend/requirements.txt index f56a292..30dfde0 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,6 +14,7 @@ google-auth>=2.36.0,<3 requests>=2.32.0,<3 psutil>=6.1.0,<8 pywinrm>=0.5.0,<1 +paramiko>=3.5.0,<4 # dev pytest>=8.3.0,<9 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 5ce03fb..ad4b7b8 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL def test_version_constants(): - assert APP_VERSION == "0.10.4" + assert APP_VERSION == "0.11.0" assert APP_NAME == "Security Alert Center" - assert APP_VERSION_LABEL == "Security Alert Center v.0.10.4" + assert APP_VERSION_LABEL == "Security Alert Center v.0.11.0" diff --git a/backend/tests/test_linux_admin_settings.py b/backend/tests/test_linux_admin_settings.py new file mode 100644 index 0000000..b0bbf8f --- /dev/null +++ b/backend/tests/test_linux_admin_settings.py @@ -0,0 +1,79 @@ +"""Linux admin settings and SSH host actions.""" + +from unittest.mock import patch + +from app.models import Host +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings +from app.services.ssh_connect import SshCommandResult, iter_ssh_targets + + +def test_put_linux_admin_settings_persists(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "") + monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "") + from app.config import get_settings + + get_settings.cache_clear() + + response = client.put( + "/api/v1/settings/linux-admin", + headers=jwt_headers, + json={"user": "root", "password": "secret-pass"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["configured"] is True + assert body["user"] == "root" + assert body["source"] == "db" + + row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID) + assert row is not None + assert row.linux_admin_user == "root" + assert row.linux_admin_password == "secret-pass" + + +def test_iter_ssh_targets_hostname_before_ip(): + host = Host( + hostname="ubabuba", + os_family="linux", + product="ssh-monitor", + ipv4="10.0.0.5", + ) + targets = iter_ssh_targets(host) + assert targets[0] == "ubabuba" + assert targets[-1] == "10.0.0.5" + + +def test_host_agent_update_via_ssh(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root") + monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + host = Host( + hostname="ubabuba", + os_family="linux", + product="ssh-monitor", + ipv4="10.0.0.5", + ) + db_session.add(host) + db_session.commit() + db_session.refresh(host) + + with patch("app.api.v1.hosts.run_ssh_monitor_update") as mock_update: + mock_update.return_value = SshCommandResult( + ok=True, + message="SSH OK", + target="ubabuba", + stdout="updated", + exit_code=0, + ) + response = client.post( + f"/api/v1/hosts/{host.id}/actions/agent-update", + headers=jwt_headers, + ) + + assert response.status_code == 200 + body = response.json() + assert body["ok"] is True + assert body["target"] == "ubabuba" diff --git a/deploy/env.native.example b/deploy/env.native.example index 242f45d..5a048ae 100644 --- a/deploy/env.native.example +++ b/deploy/env.native.example @@ -88,6 +88,10 @@ SAC_RDG_FLAP_DEDUP_SEC=30 # SAC_WIN_ADMIN_USER=B26\\Administrator # SAC_WIN_ADMIN_PASSWORD= +# SSH / update_ssh_monitor.sh на Linux-хостах +# SAC_LINUX_ADMIN_USER=root +# SAC_LINUX_ADMIN_PASSWORD= + # Retention (systemd sac-retention.timer) SAC_EVENTS_RETENTION_DAYS=90 SAC_PROBLEMS_RETENTION_DAYS=180 diff --git a/frontend/package.json b/frontend/package.json index e362dda..43a903c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "sac-ui", "private": true, - "version": "0.10.4", + "version": "0.11.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fce571f..ba65c91 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -446,6 +446,48 @@ export function testHostWinRm(hostId: number): Promise { }); } +export interface LinuxAdminSettings { + configured: boolean; + user: string | null; + password_hint: string | null; + source: string; +} + +export function fetchLinuxAdminSettings(): Promise { + return apiFetch("/api/v1/settings/linux-admin"); +} + +export function updateLinuxAdminSettings(payload: { + user?: string | null; + password?: string | null; +}): Promise { + return apiFetch("/api/v1/settings/linux-admin", { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export interface HostSshActionResult { + ok: boolean; + message: string; + target: string; + stdout: string | null; + stderr: string | null; + exit_code: number | null; +} + +export function testHostSsh(hostId: number): Promise { + return apiFetch(`/api/v1/hosts/${hostId}/actions/ssh-test`, { + method: "POST", + }); +} + +export function updateHostAgentViaSsh(hostId: number): Promise { + return apiFetch(`/api/v1/hosts/${hostId}/actions/agent-update`, { + method: "POST", + }); +} + export interface DashboardSummary { events_last_24h: number; hosts_total: number; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 6ffd3ea..52ae265 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.10.4"; +export const APP_VERSION = "0.11.0"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/HostDetailView.vue b/frontend/src/views/HostDetailView.vue index f319614..f730d67 100644 --- a/frontend/src/views/HostDetailView.vue +++ b/frontend/src/views/HostDetailView.vue @@ -58,6 +58,26 @@

{{ winRmTestMessage }}

+
+ + +

{{ sshActionMessage }}

+
{{ sshActionOutput }}
+
@@ -177,6 +197,8 @@ import { fetchHost, fetchRecentEvents, testHostWinRm, + testHostSsh, + updateHostAgentViaSsh, type EventListResponse, type HostDetail, } from "../api"; @@ -195,6 +217,11 @@ const eventsPageSize = 50; const testingWinRm = ref(false); const winRmTestMessage = ref(""); const winRmTestOk = ref(false); +const testingSsh = ref(false); +const updatingAgent = ref(false); +const sshActionMessage = ref(""); +const sshActionOk = ref(false); +const sshActionOutput = ref(""); let refreshingLive = false; useSacLiveEventStream(() => { @@ -210,6 +237,13 @@ const isWindowsHost = computed(() => { return h.product === "rdp-login-monitor"; }); +const isLinuxHost = computed(() => { + const h = host.value; + if (!h) return false; + if ((h.os_family || "").toLowerCase() === "linux") return true; + return h.product === "ssh-monitor"; +}); + const inventory = computed(() => host.value?.inventory ?? null); const windowsFields = computed(() => { @@ -289,6 +323,8 @@ async function loadHost() { loading.value = true; error.value = ""; winRmTestMessage.value = ""; + sshActionMessage.value = ""; + sshActionOutput.value = ""; try { host.value = await fetchHost(hostId.value); } catch (e) { @@ -313,6 +349,57 @@ async function runWinRmTest() { } } +function formatSshOutput(result: { stdout: string | null; stderr: string | null; exit_code: number | null }) { + const parts: string[] = []; + if (result.stdout?.trim()) { + parts.push(result.stdout.trim()); + } + if (result.stderr?.trim()) { + parts.push(result.stderr.trim()); + } + if (result.exit_code != null) { + parts.push(`exit code: ${result.exit_code}`); + } + return parts.join("\n\n"); +} + +async function runSshTest() { + testingSsh.value = true; + sshActionMessage.value = ""; + sshActionOutput.value = ""; + try { + const result = await testHostSsh(hostId.value); + sshActionOk.value = result.ok; + sshActionMessage.value = result.message; + sshActionOutput.value = formatSshOutput(result); + } catch (e) { + sshActionOk.value = false; + sshActionMessage.value = e instanceof Error ? e.message : "Ошибка проверки SSH"; + } finally { + testingSsh.value = false; + } +} + +async function runAgentUpdate() { + updatingAgent.value = true; + sshActionMessage.value = ""; + sshActionOutput.value = ""; + try { + const result = await updateHostAgentViaSsh(hostId.value); + sshActionOk.value = result.ok; + sshActionMessage.value = result.message; + sshActionOutput.value = formatSshOutput(result); + if (result.ok) { + host.value = await fetchHost(hostId.value); + } + } catch (e) { + sshActionOk.value = false; + sshActionMessage.value = e instanceof Error ? e.message : "Ошибка обновления агента"; + } finally { + updatingAgent.value = false; + } +} + async function refreshFromLatestEvent() { if (refreshingLive) return; refreshingLive = true; @@ -380,4 +467,23 @@ watch( .muted { color: #9aa4b2; } + +.host-actions { + margin-top: 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +.host-ssh-output { + margin-top: 0.75rem; + max-height: 16rem; + overflow: auto; + padding: 0.75rem; + background: #0d1117; + border-radius: 6px; + font-size: 0.85rem; + white-space: pre-wrap; +} diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 2b2ce10..9723aac 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -82,6 +82,44 @@
+
+

Linux (SSH / обновление ssh-monitor)

+

+ SSH-учётка для удалённого запуска /opt/scripts/update_ssh_monitor.sh с карточки Linux-хоста. + Рекомендуется root или пользователь с sudo NOPASSWD. + Пока запись не создана в UI — используются SAC_LINUX_ADMIN_* из sac-api.env. +

+
+ + +

+ Настроен: {{ linuxAdminLoaded.configured ? "да" : "нет" }} + · источник: {{ linuxAdminLoaded.source }} +

+
+ +
+
+
+

Правило оповещений

@@ -475,12 +513,14 @@ import { fetchMobileSettings, fetchUsers, fetchWinAdminSettings, + fetchLinuxAdminSettings, revokeMobileDevice, deleteMobileDevice, revokeMobileEnrollmentCode, testMobileDevicePush, updateMobileSettings, updateWinAdminSettings, + updateLinuxAdminSettings, type MobileDevice, type MobileEnrollmentCode, type MobileSettings, @@ -542,6 +582,13 @@ interface WinAdminSettings { source: string; } +interface LinuxAdminSettings { + configured: boolean; + user: string | null; + password_hint: string | null; + source: string; +} + interface SeverityOverrideRowApi { event_type: string; default_severity: string; @@ -556,6 +603,7 @@ const loading = ref(true); const savingPolicy = ref(false); const savingUi = ref(false); const savingWinAdmin = ref(false); +const savingLinuxAdmin = ref(false); const savingTelegram = ref(false); const savingWebhook = ref(false); const savingEmail = ref(false); @@ -574,6 +622,7 @@ const emailLoaded = ref(null); const policyLoaded = ref(null); const uiLoaded = ref(null); const winAdminLoaded = ref(null); +const linuxAdminLoaded = ref(null); const severityRows = ref([]); const severityFilter = ref(""); const mobileLoaded = ref(null); @@ -641,6 +690,11 @@ const winAdminForm = reactive({ password: "", }); +const linuxAdminForm = reactive({ + user: "", + password: "", +}); + const telegramForm = reactive({ enabled: false, bot_token: "", @@ -696,6 +750,11 @@ const winAdminPasswordPlaceholder = computed(() => ? `оставить ${winAdminLoaded.value.password_hint}` : "обязателен при первой настройке", ); +const linuxAdminPasswordPlaceholder = computed(() => + linuxAdminLoaded.value?.password_hint + ? `оставить ${linuxAdminLoaded.value.password_hint}` + : "обязателен при первой настройке", +); const filteredSeverityRows = computed(() => { const q = severityFilter.value.trim().toLowerCase(); @@ -812,6 +871,44 @@ async function saveWinAdminSettings() { } } +async function loadLinuxAdminSettings() { + const data = await fetchLinuxAdminSettings(); + linuxAdminLoaded.value = data; + linuxAdminForm.user = data.user ?? ""; + linuxAdminForm.password = ""; +} + +async function saveLinuxAdminSettings() { + savingLinuxAdmin.value = true; + error.value = ""; + success.value = ""; + try { + const payload: { user?: string; password?: string } = {}; + if (linuxAdminForm.user.trim()) { + payload.user = linuxAdminForm.user.trim(); + } + if (linuxAdminForm.password.trim()) { + payload.password = linuxAdminForm.password; + } + if (!payload.user && !payload.password) { + error.value = "Укажите логин и/или новый пароль Linux admin"; + return; + } + if (!linuxAdminLoaded.value?.configured && (!payload.user || !payload.password)) { + error.value = "Для первой настройки укажите логин и пароль"; + return; + } + linuxAdminLoaded.value = await updateLinuxAdminSettings(payload); + linuxAdminForm.user = linuxAdminLoaded.value.user ?? linuxAdminForm.user; + linuxAdminForm.password = ""; + success.value = "Учётные данные Linux admin сохранены."; + } catch (e) { + error.value = e instanceof Error ? e.message : "Ошибка сохранения Linux admin"; + } finally { + savingLinuxAdmin.value = false; + } +} + async function loadMobileSection() { mobileLoaded.value = await fetchMobileSettings(); mobileForm.devices_allowed = mobileLoaded.value.devices_allowed; @@ -958,6 +1055,7 @@ async function load() { emailForm.smtp_ssl = data.email.smtp_ssl; await loadUiSettings(); await loadWinAdminSettings(); + await loadLinuxAdminSettings(); await loadSeverityOverrides(); await loadMobileSection(); } catch (e) {