20d9473f38
Backend: - Add MAC OUI lookup table (QEMU 52:54:00, Proxmox bc:24:11, VMware, VBox, Hyper-V) - suggest_node_type() now accepts mac param and uses OUI as a low-priority hint Frontend: - Detect VM type from MAC prefix on pending device cards - Show colored badge (QEMU / PVE / VMware / VBox / Hyper-V) in orange with tooltip
173 lines
6.1 KiB
Python
173 lines
6.1 KiB
Python
"""Network scanner: ARP sweep + nmap service detection."""
|
|
import asyncio
|
|
import logging
|
|
import socket
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.models import PendingDevice, ScanRun
|
|
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
import nmap
|
|
_NMAP_AVAILABLE = True
|
|
except ImportError:
|
|
_NMAP_AVAILABLE = False
|
|
logger.warning("python-nmap not available — scanner will run in mock mode")
|
|
|
|
|
|
def _nmap_scan(target: str) -> list[dict[str, Any]]:
|
|
"""Run nmap -sV --open on target, return list of host dicts."""
|
|
if not _NMAP_AVAILABLE:
|
|
return _mock_scan(target)
|
|
|
|
nm = nmap.PortScanner()
|
|
try:
|
|
# Home lab port range: standard top-1000 + common self-hosted service ports
|
|
extra_ports = (
|
|
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
|
|
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
|
|
"3389,4711,5000,5001,5432,5601,5900,5984,6052,6379,6432,6443,"
|
|
"6767,6789,6800,7878,8000,8006,8080,8081,8086,8088,8090,8096,"
|
|
"8112,8123,8200,8291,8428,8443,8554,8686,8789,8843,8880,8883,"
|
|
"8971,8989,9000,9001,9090,9091,9092,9093,9100,9117,9200,9300,"
|
|
"9411,9443,9696,10051,16686,34567,37777,51413,64738"
|
|
)
|
|
nm.scan(hosts=target, arguments=f"-sV --open -T4 --host-timeout 120s -p {extra_ports}")
|
|
except Exception as exc:
|
|
logger.error("nmap scan failed: %s", exc)
|
|
raise RuntimeError(str(exc)) from exc
|
|
|
|
hosts = []
|
|
for host in nm.all_hosts():
|
|
if nm[host].state() != "up":
|
|
continue
|
|
open_ports = []
|
|
for proto in nm[host].all_protocols():
|
|
for port, info in nm[host][proto].items():
|
|
if info["state"] == "open":
|
|
open_ports.append({
|
|
"port": port,
|
|
"protocol": proto,
|
|
"banner": info.get("product", "") + " " + info.get("version", ""),
|
|
})
|
|
hosts.append({
|
|
"ip": host,
|
|
"hostname": _resolve_hostname(host),
|
|
"mac": nm[host].get("addresses", {}).get("mac"),
|
|
"os": _extract_os(nm, host),
|
|
"open_ports": open_ports,
|
|
})
|
|
return hosts
|
|
|
|
|
|
def _resolve_hostname(ip: str) -> str | None:
|
|
try:
|
|
return socket.gethostbyaddr(ip)[0]
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _extract_os(nm: object, host: str) -> str | None:
|
|
try:
|
|
osmatch = nm[host].get("osmatch", []) # type: ignore[index]
|
|
if osmatch:
|
|
return str(osmatch[0]["name"])
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _mock_scan(target: str) -> list[dict[str, Any]]:
|
|
"""Return fake results for dev/test environments without nmap."""
|
|
return [
|
|
{
|
|
"ip": "192.168.1.99",
|
|
"hostname": "unknown-device.lan",
|
|
"mac": "AA:BB:CC:DD:EE:FF",
|
|
"os": None,
|
|
"open_ports": [
|
|
{"port": 80, "protocol": "tcp", "banner": "nginx"},
|
|
{"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.0"},
|
|
],
|
|
}
|
|
]
|
|
|
|
|
|
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|
"""Execute scan for given CIDR ranges and populate pending_devices."""
|
|
# Avoid circular import
|
|
from sqlalchemy import select
|
|
|
|
from app.api.routes.status import broadcast_scan_update
|
|
|
|
devices_found = 0
|
|
try:
|
|
for cidr in ranges:
|
|
# Run nmap in a thread pool — does not block the event loop
|
|
hosts = await asyncio.to_thread(_nmap_scan, cidr)
|
|
|
|
for host in hosts:
|
|
services = fingerprint_ports(host["open_ports"])
|
|
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
|
|
|
|
# Update existing pending device or create a new one
|
|
existing_result = await db.execute(
|
|
select(PendingDevice).where(
|
|
PendingDevice.ip == host["ip"],
|
|
PendingDevice.status == "pending",
|
|
)
|
|
)
|
|
existing = existing_result.scalar_one_or_none()
|
|
if existing:
|
|
existing.mac = host.get("mac") or existing.mac
|
|
existing.hostname = host.get("hostname") or existing.hostname
|
|
existing.os = host.get("os") or existing.os
|
|
existing.services = services
|
|
existing.suggested_type = suggested_type
|
|
else:
|
|
device = PendingDevice(
|
|
ip=host["ip"],
|
|
mac=host.get("mac"),
|
|
hostname=host.get("hostname"),
|
|
os=host.get("os"),
|
|
services=services,
|
|
suggested_type=suggested_type,
|
|
status="pending",
|
|
)
|
|
db.add(device)
|
|
devices_found += 1
|
|
|
|
# Commit immediately so the device is visible right away
|
|
await db.commit()
|
|
|
|
# Update running count on the scan run record
|
|
run = await db.get(ScanRun, run_id)
|
|
if run:
|
|
run.devices_found = devices_found
|
|
await db.commit()
|
|
|
|
# Push WS event so the frontend refreshes pending panel
|
|
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
|
|
|
# Mark scan as done
|
|
run = await db.get(ScanRun, run_id)
|
|
if run:
|
|
run.status = "done"
|
|
run.devices_found = devices_found
|
|
run.finished_at = datetime.now(timezone.utc)
|
|
await db.commit()
|
|
|
|
except Exception as exc:
|
|
logger.error("Scan failed: %s", exc)
|
|
run = await db.get(ScanRun, run_id)
|
|
if run:
|
|
run.status = "error"
|
|
run.error = str(exc)
|
|
run.finished_at = datetime.now(timezone.utc)
|
|
await db.commit()
|