""" 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), )