feat: Linux SSH admin and remote ssh-monitor update (0.11.0)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user