utp_service/backend/app/services/discovery/ssh_client.py

278 lines
10 KiB
Python

"""
Low-level SSH operations for device discovery.
No SQLAlchemy, no business logic — pure network I/O.
"""
import asyncio
import logging
import re
import asyncssh
logger = logging.getLogger(__name__)
# ── Single connection helpers (used internally and for legacy paths) ──────────
async def read_remote_file(
host: str,
port: int,
username: str,
password: str,
remote_path: str,
) -> str:
"""SSH into host and return the contents of remote_path."""
async with asyncssh.connect(
host, port=port, username=username, password=password,
known_hosts=None, connect_timeout=10,
) as conn:
result = await conn.run(f"cat {remote_path}", check=True)
return result.stdout
async def get_ip_neighbors(
host: str, port: int, username: str, password: str,
) -> list[str]:
"""Run `ip neigh show` and return REACHABLE/DELAY neighbour IPs."""
try:
async with asyncssh.connect(
host, port=port, username=username, password=password,
known_hosts=None, connect_timeout=10,
) as conn:
result = await conn.run("ip neigh show", check=False)
return _parse_neigh_output(result.stdout)
except Exception as exc:
logger.warning("[discovery] ip neigh failed on %s: %s", host, exc)
return []
# ── Multi-auth helpers ────────────────────────────────────────────────────────
async def ssh_run_multi(
host: str, port: int, options: list, command: str, timeout: int = 15,
) -> str:
"""Try each SSH auth option in order; return stdout on first success or raise."""
last_exc: Exception = Exception("No auth options provided")
for i, opt in enumerate(options):
_log_attempt(i, len(options), host, opt)
try:
connect_kwargs = _build_connect_kwargs(host, port, opt)
async with asyncssh.connect(**connect_kwargs) as conn:
result = await asyncio.wait_for(
conn.run(command, check=True), timeout=timeout
)
logger.info(
"[discovery] SSH auth succeeded for %s with %s(%s)",
host, opt.type, opt.username,
)
return result.stdout
except Exception as exc:
last_exc = exc
logger.info(
"[discovery] SSH auth failed for %s with %s(%s): %s",
host, opt.type, opt.username, exc,
)
raise last_exc
async def get_ip_neighbors_multi(host: str, port: int, options: list) -> list[str]:
"""Multi-auth version of get_ip_neighbors."""
try:
stdout = await ssh_run_multi(host, port, options, "ip neigh show", timeout=15)
return _parse_neigh_output(stdout)
except Exception as exc:
logger.warning("[discovery] ip neigh failed on %s: %s", host, exc)
return []
async def discover_mikrotik_multi(host: str, port: int, options: list) -> str | None:
"""Discover MikroTik IP via SSH_CLIENT environment variable (multi-auth)."""
try:
stdout = await ssh_run_multi(host, port, options, "echo $SSH_CLIENT", timeout=15)
parts = stdout.strip().split()
return parts[0] if parts else None
except Exception as exc:
logger.warning("[discovery] Failed to discover MikroTik via SSH_CLIENT: %s", exc)
return None
async def fetch_slave_config(
host: str, port: int, options: list, config_path: str,
) -> str | None:
"""Read a remote config file from a slave candidate. Returns None on failure."""
try:
return await ssh_run_multi(host, port, options, f"cat {config_path}", timeout=15)
except Exception:
return None
async def discover_proxmox_via_port_scan(
host: str, port: int, username: str, password: str,
) -> list[str]:
"""Discover Proxmox servers on the local subnet by scanning port 8006.
Note: uses single-auth (password) because it's called from the legacy path.
Migrate to multi-auth when needed.
"""
try:
async with asyncssh.connect(
host, port=port, username=username, password=password,
known_hosts=None, connect_timeout=10,
) as conn:
scan_script = (
"subnet=$(ip -o -f inet addr show | awk '!/ lo |docker|br-|wg/ {print $4; exit}' | "
"cut -d/ -f1 | awk -F. '{print $1\".\"$2\".\"$3}') && "
"(set +m; for ip in $subnet.{1..254}; do "
"timeout 0.3 bash -c \"echo > /dev/tcp/$ip/8006\" 2>/dev/null && echo \"$ip\" & "
"done; wait)"
)
result = await conn.run(scan_script, check=False)
return [ln.strip() for ln in result.stdout.strip().split("\n") if ln.strip()]
except Exception as exc:
logger.warning("[discovery] Proxmox port scan failed on %s: %s", host, exc)
return []
# ── Tunnel ────────────────────────────────────────────────────────────────────
async def open_ssh_tunnel(
tunnel_host: str,
tunnel_port: int,
ssh_options: list,
target_host: str,
target_port: int,
):
"""Open an asyncssh connection, trying each option in order.
Returns an open asyncssh connection on first success, or raises.
The caller is responsible for closing the connection.
"""
last_exc: Exception = Exception("No SSH options provided")
for opt in ssh_options:
try:
connect_kwargs = _build_connect_kwargs(tunnel_host, tunnel_port, opt)
if opt.type == "key":
logger.info(
"[discovery] Tunnel attempt for %s — key: user=%s key_path=%s",
tunnel_host, opt.username, opt.key_path,
)
else:
masked = _mask(opt.password)
logger.info(
"[discovery] Tunnel attempt for %s — password: user=%s password=%s",
tunnel_host, opt.username, masked,
)
conn = await asyncssh.connect(**connect_kwargs)
logger.info(
"[discovery] Tunnel auth succeeded for %s with %s(%s)",
tunnel_host, opt.type, opt.username,
)
return conn
except Exception as exc:
last_exc = exc
logger.info(
"[discovery] Tunnel auth failed for %s with %s(%s): %s",
tunnel_host, opt.type, opt.username, exc,
)
raise last_exc
# ── MikroTik verification ─────────────────────────────────────────────────────
async def verify_is_mikrotik(
host: str,
ssh_port: int = 22,
ssh_options: list | None = None,
timeout: float = 3.0,
) -> bool:
"""Check whether host is actually a MikroTik RouterOS device.
Two independent probes are tried (first positive result wins):
1. TCP connect to port 8291 (WinBox) — exclusively used by MikroTik.
2. SSH banner check — RouterOS advertises "ROSSSH" as its SSH version.
Returns True if either probe confirms MikroTik, False otherwise.
"""
# Probe 1: WinBox port 8291 (MikroTik-exclusive)
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, 8291), timeout=timeout
)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
logger.info("[discovery] %s — MikroTik confirmed via WinBox port 8291", host)
return True
except Exception:
pass
# Probe 2: SSH banner
if ssh_options:
for opt in ssh_options:
try:
kwargs = _build_connect_kwargs(host, ssh_port, opt)
kwargs["connect_timeout"] = int(timeout)
async with asyncssh.connect(**kwargs) as conn:
banner = (conn.get_extra_info("server_version") or "").lower()
if "rosssh" in banner or "mikrotik" in banner:
logger.info(
"[discovery] %s — MikroTik confirmed via SSH banner: %s", host, banner
)
return True
logger.info(
"[discovery] %s — SSH connected but banner does not match MikroTik: %s",
host, banner,
)
return False # Connected successfully — definitely not MikroTik
except Exception:
continue
logger.info("[discovery] %s — could not verify MikroTik (no probe succeeded)", host)
return False
# ── Internal helpers ──────────────────────────────────────────────────────────
def _parse_neigh_output(stdout: str) -> list[str]:
ips = []
for line in stdout.strip().splitlines():
parts = line.split()
if not parts:
continue
ip = parts[0]
state = parts[-1]
if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", ip) and state in ("REACHABLE", "DELAY"):
ips.append(ip)
return ips
def _build_connect_kwargs(host: str, port: int, opt) -> dict:
kwargs: dict = dict(
host=host, port=port, username=opt.username,
known_hosts=None, connect_timeout=10,
)
if opt.type == "key":
kwargs["client_keys"] = [opt.key_path]
kwargs["passphrase"] = None
else:
kwargs["password"] = opt.password
return kwargs
def _mask(password: str) -> str:
return (password[:1] + "***" + password[-1:]) if len(password) > 2 else "***"
def _log_attempt(idx: int, total: int, host: str, opt) -> None:
if opt.type == "key":
logger.info(
"[discovery] SSH attempt %d/%d for %s — key: user=%s key_path=%s",
idx + 1, total, host, opt.username, opt.key_path,
)
else:
logger.info(
"[discovery] SSH attempt %d/%d for %s — password: user=%s password=%s",
idx + 1, total, host, opt.username, _mask(opt.password),
)