feat: Linux SSH admin and remote ssh-monitor update (0.11.0)

This commit is contained in:
2026-06-20 00:25:35 +10:00
parent 279ea925a8
commit cc8f50de89
17 changed files with 805 additions and 5 deletions
+98
View File
@@ -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,
+47
View File
@@ -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,
)
+4
View File
@@ -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
+2
View File
@@ -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)
@@ -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)
+212
View File
@@ -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,
)
+1 -1
View File
@@ -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}"