Files
the-other-dude/backend/app/services/scanner.py
Jason Staack b840047e19 feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:30:44 -05:00

125 lines
3.5 KiB
Python

"""
Subnet scanner for MikroTik device discovery.
Scans a CIDR range by attempting TCP connections to RouterOS API ports
(8728 and 8729) with configurable concurrency limits and timeouts.
Security constraints:
- CIDR range limited to /20 or smaller (4096 IPs maximum)
- Maximum 50 concurrent connections to prevent network flooding
- 2-second timeout per connection attempt
"""
import asyncio
import ipaddress
import socket
from typing import Optional
from app.schemas.device import SubnetScanResult
# Maximum concurrency for TCP probes
_MAX_CONCURRENT = 50
# Timeout (seconds) per TCP connection attempt
_TCP_TIMEOUT = 2.0
# RouterOS API port
_API_PORT = 8728
# RouterOS SSL API port
_SSL_PORT = 8729
async def _probe_host(
semaphore: asyncio.Semaphore,
ip_str: str,
) -> Optional[SubnetScanResult]:
"""
Probe a single IP for RouterOS API ports.
Returns a SubnetScanResult if either port is open, None otherwise.
"""
async with semaphore:
api_open, ssl_open = await asyncio.gather(
_tcp_connect(ip_str, _API_PORT),
_tcp_connect(ip_str, _SSL_PORT),
return_exceptions=False,
)
if not api_open and not ssl_open:
return None
# Attempt reverse DNS (best-effort; won't fail the scan)
hostname = await _reverse_dns(ip_str)
return SubnetScanResult(
ip_address=ip_str,
hostname=hostname,
api_port_open=api_open,
api_ssl_port_open=ssl_open,
)
async def _tcp_connect(ip: str, port: int) -> bool:
"""Return True if a TCP connection to ip:port succeeds within _TCP_TIMEOUT."""
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection(ip, port),
timeout=_TCP_TIMEOUT,
)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
return True
except Exception:
return False
async def _reverse_dns(ip: str) -> Optional[str]:
"""Attempt a reverse DNS lookup. Returns None on failure."""
try:
loop = asyncio.get_running_loop()
hostname, _, _ = await asyncio.wait_for(
loop.run_in_executor(None, socket.gethostbyaddr, ip),
timeout=1.5,
)
return hostname
except Exception:
return None
async def scan_subnet(cidr: str) -> list[SubnetScanResult]:
"""
Scan a CIDR range for hosts with open RouterOS API ports.
Args:
cidr: CIDR notation string, e.g. "192.168.1.0/24".
Must be /20 or smaller (validated by SubnetScanRequest).
Returns:
List of SubnetScanResult for each host with at least one open API port.
Raises:
ValueError: If CIDR is malformed or too large.
"""
try:
network = ipaddress.ip_network(cidr, strict=False)
except ValueError as e:
raise ValueError(f"Invalid CIDR: {e}") from e
if network.num_addresses > 4096:
raise ValueError(
f"CIDR range too large ({network.num_addresses} addresses). "
"Maximum allowed is /20 (4096 addresses)."
)
# Skip network address and broadcast address for IPv4
hosts = list(network.hosts()) if network.num_addresses > 2 else list(network)
semaphore = asyncio.Semaphore(_MAX_CONCURRENT)
tasks = [_probe_host(semaphore, str(ip)) for ip in hosts]
results = await asyncio.gather(*tasks, return_exceptions=False)
# Filter out None (hosts with no open ports)
return [r for r in results if r is not None]