diff --git a/backend/app/services/discovery/orchestrator.py b/backend/app/services/discovery/orchestrator.py index 5ede2e3..145ed12 100644 --- a/backend/app/services/discovery/orchestrator.py +++ b/backend/app/services/discovery/orchestrator.py @@ -32,6 +32,7 @@ from .ssh_client import ( fetch_slave_config, get_ip_neighbors_multi, ssh_run_multi, + verify_is_mikrotik, ) from .types import DeviceAction, DiscoveredDevice, DiscoveryResult, ProgressCallback @@ -811,7 +812,15 @@ async def _discover_mikrotik( logger.info("[discovery] MikroTik: no IP found via SSH_CLIENT") return - logger.info("[discovery] MikroTik found at %s", mikrotik_ip) + logger.info("[discovery] MikroTik candidate: %s — verifying...", mikrotik_ip) + await _emit("disc_action", {"message": f"Проверка MikroTik {mikrotik_ip} (WinBox/SSH)..."}) + is_mikrotik = await verify_is_mikrotik(mikrotik_ip, ssh_port, ssh_options) + if not is_mikrotik: + logger.info("[discovery] %s — NOT a MikroTik (WinBox and SSH banner check failed), skipping", mikrotik_ip) + await _emit("disc_action", {"message": f"{mikrotik_ip} — не MikroTik, пропускаем"}) + return + + logger.info("[discovery] MikroTik confirmed at %s", mikrotik_ip) await _emit("disc_infra_found", {"type": "mikrotik", "ip": mikrotik_ip}) try: existing = (await db.execute( diff --git a/backend/app/services/discovery/ssh_client.py b/backend/app/services/discovery/ssh_client.py index 06d726c..57b6bfb 100644 --- a/backend/app/services/discovery/ssh_client.py +++ b/backend/app/services/discovery/ssh_client.py @@ -177,6 +177,62 @@ async def open_ssh_tunnel( 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]: