feat: SSH card UX, agent version sync, persistent ssh_admin_ok (0.11.5)
Silent SSH probe on host card open; manual test feedback auto-hides. Persist ssh_admin_ok in DB (migration 019). After agent update read version from host and refresh UI.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
"""hosts: persist Linux SSH admin check status
|
||||
|
||||
Revision ID: 019
|
||||
Revises: 018
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "019"
|
||||
down_revision: Union[str, None] = "018"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("hosts", sa.Column("ssh_admin_ok", sa.Boolean(), nullable=True))
|
||||
op.add_column("hosts", sa.Column("ssh_admin_checked_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("hosts", "ssh_admin_checked_at")
|
||||
op.drop_column("hosts", "ssh_admin_ok")
|
||||
@@ -3,6 +3,8 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.auth.jwt_auth import get_current_user, require_admin
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
@@ -151,9 +153,16 @@ def _host_detail_from_model(
|
||||
inventory=host.inventory if isinstance(host.inventory, dict) else None,
|
||||
inventory_updated_at=host.inventory_updated_at,
|
||||
created_at=host.created_at,
|
||||
ssh_admin_ok=host.ssh_admin_ok,
|
||||
ssh_admin_checked_at=host.ssh_admin_checked_at,
|
||||
)
|
||||
|
||||
|
||||
def _set_ssh_admin_status(host: Host, ok: bool) -> None:
|
||||
host.ssh_admin_ok = ok
|
||||
host.ssh_admin_checked_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@router.get("/{host_id}", response_model=HostDetail)
|
||||
def get_host(
|
||||
host_id: int,
|
||||
@@ -189,9 +198,16 @@ class HostSshActionResponse(BaseModel):
|
||||
stdout: str | None = None
|
||||
stderr: str | None = None
|
||||
exit_code: int | None = None
|
||||
product_version: str | None = None
|
||||
ssh_admin_ok: bool | None = None
|
||||
|
||||
|
||||
def _ssh_action_response(result: SshCommandResult, *, attempts: list[str] | None = None) -> HostSshActionResponse:
|
||||
def _ssh_action_response(
|
||||
result: SshCommandResult,
|
||||
*,
|
||||
attempts: list[str] | None = None,
|
||||
ssh_admin_ok: bool | None = None,
|
||||
) -> HostSshActionResponse:
|
||||
message = result.message
|
||||
if attempts:
|
||||
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||
@@ -202,6 +218,8 @@ def _ssh_action_response(result: SshCommandResult, *, attempts: list[str] | None
|
||||
stdout=result.stdout or None,
|
||||
stderr=result.stderr or None,
|
||||
exit_code=result.exit_code,
|
||||
product_version=result.agent_version,
|
||||
ssh_admin_ok=ssh_admin_ok,
|
||||
)
|
||||
|
||||
|
||||
@@ -313,7 +331,10 @@ def test_host_ssh(
|
||||
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)
|
||||
response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
|
||||
_set_ssh_admin_status(host, response.ok)
|
||||
db.commit()
|
||||
return response.model_copy(update={"ssh_admin_ok": host.ssh_admin_ok})
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/agent-update", response_model=HostSshActionResponse)
|
||||
@@ -330,7 +351,17 @@ def update_host_agent_via_ssh(
|
||||
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)
|
||||
response = _run_ssh_action_on_host(host, cfg, action=run_ssh_monitor_update)
|
||||
_set_ssh_admin_status(host, response.ok)
|
||||
if response.ok and response.product_version:
|
||||
host.product_version = response.product_version
|
||||
db.commit()
|
||||
return response.model_copy(
|
||||
update={
|
||||
"product_version": host.product_version if response.ok else response.product_version,
|
||||
"ssh_admin_ok": host.ssh_admin_ok,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{host_id}", response_model=HostDeleteResponse)
|
||||
|
||||
@@ -24,6 +24,8 @@ class Host(Base):
|
||||
inventory: Mapped[dict | None] = mapped_column(JSONB)
|
||||
inventory_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
|
||||
ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
||||
ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ class HostDetail(HostSummary):
|
||||
inventory: dict | None = None
|
||||
inventory_updated_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
ssh_admin_ok: bool | None = None
|
||||
ssh_admin_checked_at: datetime | None = None
|
||||
|
||||
|
||||
class HostListResponse(BaseModel):
|
||||
|
||||
@@ -9,7 +9,13 @@ from dataclasses import dataclass
|
||||
from app.models import Host
|
||||
|
||||
SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh"
|
||||
SSH_MONITOR_BINARY = "/usr/local/bin/ssh-monitor"
|
||||
SSH_MONITOR_VERSION_CMD = (
|
||||
f"grep -m1 '^SSH_MONITOR_VERSION=' {SSH_MONITOR_BINARY} 2>/dev/null "
|
||||
"| sed -E 's/^SSH_MONITOR_VERSION=[\"'\\'']*([^\"'\\'']+)[\"'\\'']*$/\\1/'"
|
||||
)
|
||||
SSH_OUTPUT_MAX_LEN = 12_000
|
||||
_AGENT_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:-SAC)?)", re.IGNORECASE)
|
||||
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
||||
|
||||
|
||||
@@ -33,6 +39,7 @@ class SshCommandResult:
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
exit_code: int | None = None
|
||||
agent_version: str | None = None
|
||||
|
||||
|
||||
def is_linux_host(host: Host) -> bool:
|
||||
@@ -233,6 +240,37 @@ def test_ssh_connection(
|
||||
return result
|
||||
|
||||
|
||||
def parse_ssh_monitor_version_text(text: str) -> str | None:
|
||||
if not text:
|
||||
return None
|
||||
match = _AGENT_VERSION_RE.search(text)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def probe_ssh_monitor_version(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
) -> str | None:
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=SSH_MONITOR_VERSION_CMD,
|
||||
command_timeout_sec=30,
|
||||
)
|
||||
if result.ok and result.stdout.strip():
|
||||
version = parse_ssh_monitor_version_text(result.stdout)
|
||||
if version:
|
||||
return version
|
||||
if result.stdout or result.stderr:
|
||||
return parse_ssh_monitor_version_text(f"{result.stdout}\n{result.stderr}")
|
||||
return None
|
||||
|
||||
|
||||
def run_ssh_monitor_update(
|
||||
*,
|
||||
target: str,
|
||||
@@ -258,7 +296,7 @@ def run_ssh_monitor_update(
|
||||
exit_code=probe.exit_code,
|
||||
)
|
||||
|
||||
return run_ssh_command(
|
||||
updated = run_ssh_command(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
@@ -266,3 +304,21 @@ def run_ssh_monitor_update(
|
||||
command_timeout_sec=900,
|
||||
need_root=True,
|
||||
)
|
||||
if not updated.ok:
|
||||
return updated
|
||||
|
||||
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}"
|
||||
return SshCommandResult(
|
||||
ok=True,
|
||||
message=message,
|
||||
target=updated.target,
|
||||
stdout=updated.stdout,
|
||||
stderr=updated.stderr,
|
||||
exit_code=updated.exit_code,
|
||||
agent_version=version,
|
||||
)
|
||||
return updated
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||
|
||||
APP_NAME = "Security Alert Center"
|
||||
APP_VERSION = "0.11.2"
|
||||
APP_VERSION = "0.11.5"
|
||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||
|
||||
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
||||
|
||||
|
||||
def test_version_constants():
|
||||
assert APP_VERSION == "0.11.2"
|
||||
assert APP_VERSION == "0.11.5"
|
||||
assert APP_NAME == "Security Alert Center"
|
||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.11.2"
|
||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.11.5"
|
||||
|
||||
@@ -116,6 +116,11 @@ def test_host_ssh_test_success(jwt_headers, client, db_session, monkeypatch):
|
||||
assert body["ok"] is True
|
||||
assert "hostname=ubabuba" in body["message"]
|
||||
assert body["target"] == "ubabuba"
|
||||
assert body["ssh_admin_ok"] is True
|
||||
|
||||
db_session.refresh(host)
|
||||
assert host.ssh_admin_ok is True
|
||||
assert host.ssh_admin_checked_at is not None
|
||||
|
||||
|
||||
def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch):
|
||||
@@ -149,3 +154,5 @@ def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch)
|
||||
body = response.json()
|
||||
assert body["ok"] is True
|
||||
assert body["target"] == "ubabuba"
|
||||
if "product_version" in body:
|
||||
assert body["product_version"] is None or isinstance(body["product_version"], str)
|
||||
|
||||
@@ -182,13 +182,21 @@ def test_run_ssh_monitor_update_runs_script(monkeypatch):
|
||||
target="h",
|
||||
stdout="ready\n",
|
||||
)
|
||||
assert kwargs["remote_cmd"] == "/opt/scripts/update_ssh_monitor.sh"
|
||||
assert kwargs.get("need_root") is True
|
||||
if calls == 2:
|
||||
assert kwargs["remote_cmd"] == "/opt/scripts/update_ssh_monitor.sh"
|
||||
assert kwargs.get("need_root") is True
|
||||
return ssh_connect.SshCommandResult(
|
||||
ok=True,
|
||||
message="SSH OK",
|
||||
target="ubabuba",
|
||||
stdout="SUMMARY updated 2.1.0-SAC\n",
|
||||
exit_code=0,
|
||||
)
|
||||
return ssh_connect.SshCommandResult(
|
||||
ok=True,
|
||||
message="SSH OK",
|
||||
message="ok",
|
||||
target="ubabuba",
|
||||
stdout="SUMMARY updated\n",
|
||||
stdout='2.1.0-SAC\n',
|
||||
exit_code=0,
|
||||
)
|
||||
|
||||
@@ -196,4 +204,11 @@ def test_run_ssh_monitor_update_runs_script(monkeypatch):
|
||||
|
||||
result = run_ssh_monitor_update(target="ubabuba", user="root", password="pw")
|
||||
assert result.ok is True
|
||||
assert calls == 2
|
||||
assert result.agent_version == "2.1.0-SAC"
|
||||
assert calls == 3
|
||||
|
||||
|
||||
def test_parse_ssh_monitor_version_text():
|
||||
assert ssh_connect.parse_ssh_monitor_version_text("SSH_MONITOR_VERSION=2.1.0-SAC") == "2.1.0-SAC"
|
||||
assert ssh_connect.parse_ssh_monitor_version_text("SUMMARY updated 2.1.1-SAC done") == "2.1.1-SAC"
|
||||
assert ssh_connect.parse_ssh_monitor_version_text("no version") is None
|
||||
|
||||
Reference in New Issue
Block a user