diff --git a/backend/app/scanner/dns_lookup.py b/backend/app/scanner/dns_lookup.py index 179eb4d..6bc9df6 100644 --- a/backend/app/scanner/dns_lookup.py +++ b/backend/app/scanner/dns_lookup.py @@ -6,15 +6,15 @@ Resolution is attempted in two stages per host: 1. Standard PTR lookup via socket.gethostbyaddr() - 2. mDNS fallback via avahi-resolve (if installed), catching .local names - that PTR misses — common for IoT, NAS, Raspberry Pis, and Apple devices. + 2. Direct mDNS PTR query to 224.0.0.251:5353 — no avahi-daemon required. + Works inside Docker containers using network_mode: host. """ from __future__ import annotations import logging import socket -import subprocess +import struct from concurrent.futures import ThreadPoolExecutor from app.scanner.nmap_scan import NmapHost @@ -23,6 +23,8 @@ _LOOKUP_TIMEOUT_SECONDS = 2 _MAX_WORKERS = 20 +_MDNS_ADDR = "224.0.0.251" +_MDNS_PORT = 5353 def _rdns(ip: str) -> str: @@ -41,35 +43,130 @@ def _rdns(ip: str) -> str: socket.setdefaulttimeout(None) -def _avahi_resolve(ip: str) -> str: +# ── mDNS helpers ────────────────────────────────────────────────────────────── + + +def _encode_dns_name(name: str) -> bytes: + """Encode a dotted DNS name into label-length-prefixed wire format.""" + out = b"" + for label in name.rstrip(".").split("."): + enc = label.encode("ascii") + out += bytes([len(enc)]) + enc + return out + b"\x00" + + +def _read_dns_name(data: bytes, offset: int) -> tuple[str, int]: + """ + Decode a DNS name at *offset*, following compression pointers. + Returns (name, new_offset_after_name). """ - Return the mDNS hostname for *ip* via avahi-resolve, or empty string. + parts: list[str] = [] + end_offset: int | None = None + + while offset < len(data): + length = data[offset] + if length == 0: + if end_offset is None: + end_offset = offset + 1 + break + if length & 0xC0 == 0xC0: # compression pointer + if end_offset is None: + end_offset = offset + 2 + ptr = struct.unpack("!H", data[offset : offset + 2])[0] & 0x3FFF + offset = ptr + continue + offset += 1 + parts.append(data[offset : offset + length].decode("ascii", errors="replace")) + offset += length + + return ".".join(parts), (end_offset if end_offset is not None else offset + 1) + + +def _skip_dns_name(data: bytes, offset: int) -> int: + """Advance *offset* past a DNS name and return the new position.""" + while offset < len(data): + length = data[offset] + if length == 0: + return offset + 1 + if length & 0xC0 == 0xC0: + return offset + 2 + offset += length + 1 + return offset + + +def _mdns_ptr_query(ip: str) -> str: + """ + Send a unicast-requesting mDNS PTR query directly to the multicast group + (224.0.0.251:5353) and return the first PTR hostname found, or "". - Gracefully degrades when avahi-utils is not installed (FileNotFoundError) - or when the daemon is not running / has no record for the IP. + Does not require avahi-daemon or any system service — only a UDP socket + on the host network namespace (network_mode: host). """ + parts = ip.split(".") + if len(parts) != 4: + return "" + ptr_name = ".".join(reversed(parts)) + ".in-addr.arpa" + + # DNS header: ID=0, FLAGS=0 (query, no recursion), QDCOUNT=1 + header = struct.pack("!HHHHHH", 0, 0x0000, 1, 0, 0, 0) + # Question: name + QTYPE=PTR(12) + QCLASS=IN with QU bit (0x8001 → unicast response) + question = _encode_dns_name(ptr_name) + struct.pack("!HH", 12, 0x8001) + packet = header + question + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: - result = subprocess.run( # noqa: S603 — fixed command list, no user input - ["avahi-resolve", "--address", ip], # noqa: S607 — well-known system utility; full path not portable - capture_output=True, - text=True, - timeout=_LOOKUP_TIMEOUT_SECONDS, - ) - if result.returncode == 0 and result.stdout.strip(): - # Output format: "\t" - parts = result.stdout.strip().split() - if len(parts) >= 2: - return parts[-1].rstrip(".") - except (FileNotFoundError, subprocess.TimeoutExpired, OSError): - pass + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255) + sock.settimeout(_LOOKUP_TIMEOUT_SECONDS) + sock.sendto(packet, (_MDNS_ADDR, _MDNS_PORT)) + + data, _ = sock.recvfrom(4096) + return _parse_mdns_ptr(data) + except (OSError, struct.error): + return "" + finally: + sock.close() + + +def _parse_mdns_ptr(data: bytes) -> str: + """Extract the first PTR RDATA name from a raw DNS/mDNS response.""" + if len(data) < 12: + return "" + + qdcount = struct.unpack("!H", data[4:6])[0] + ancount = struct.unpack("!H", data[6:8])[0] + if ancount == 0: + return "" + + offset = 12 + for _ in range(qdcount): + offset = _skip_dns_name(data, offset) + offset += 4 # QTYPE + QCLASS + + for _ in range(ancount): + offset = _skip_dns_name(data, offset) # owner name + if offset + 10 > len(data): + break + rtype = struct.unpack("!H", data[offset : offset + 2])[0] + offset += 8 # TYPE + CLASS + TTL + rdlength = struct.unpack("!H", data[offset : offset + 2])[0] + offset += 2 + if rtype == 12: # PTR + name, _ = _read_dns_name(data, offset) + return name.rstrip(".") + offset += rdlength + return "" +# ── public API ──────────────────────────────────────────────────────────────── + + def _resolve(ip: str) -> str: - """Try PTR first, fall back to avahi-resolve for mDNS/.local names.""" + """Try PTR first, fall back to direct mDNS for .local names.""" name = _rdns(ip) if not name: - name = _avahi_resolve(ip) + name = _mdns_ptr_query(ip) return name diff --git a/backend/tests/test_enrichment.py b/backend/tests/test_enrichment.py index a774b67..b28eaef 100644 --- a/backend/tests/test_enrichment.py +++ b/backend/tests/test_enrichment.py @@ -6,7 +6,7 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from app.scanner.dns_lookup import resolve_hostnames @@ -227,60 +227,79 @@ def fake_rdns(ip): assert host.hostname == f"host-{i}.local" @pytest.mark.unit - def test_avahi_resolve_used_when_ptr_fails(self): - """When PTR returns nothing, avahi-resolve should be tried as fallback.""" + def test_mdns_used_when_ptr_fails(self): + """When PTR returns nothing, direct mDNS query should be tried as fallback.""" host = NmapHost(ip="192.168.1.50", hostname="") - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = "192.168.1.50\tpi.local\n" with ( patch("app.scanner.dns_lookup.socket.gethostbyaddr", side_effect=OSError), - patch("app.scanner.dns_lookup.subprocess.run", return_value=mock_result), + patch( + "app.scanner.dns_lookup._mdns_ptr_query", + return_value="pi.local", + ), ): resolve_hostnames([host]) assert host.hostname == "pi.local" @pytest.mark.unit - def test_avahi_not_installed_falls_back_silently(self): - """FileNotFoundError from avahi-resolve must not raise; hostname stays empty.""" + def test_mdns_oserror_falls_back_silently(self): + """OSError inside the mDNS socket must not propagate; hostname stays empty.""" host = NmapHost(ip="192.168.1.51", hostname="") with ( patch("app.scanner.dns_lookup.socket.gethostbyaddr", side_effect=OSError), patch( - "app.scanner.dns_lookup.subprocess.run", - side_effect=FileNotFoundError("avahi-resolve not found"), + "app.scanner.dns_lookup._mdns_ptr_query", + return_value="", # _mdns_ptr_query swallows OSError internally ), ): resolve_hostnames([host]) assert host.hostname == "" @pytest.mark.unit - def test_avahi_timeout_falls_back_silently(self): - """subprocess.TimeoutExpired from avahi-resolve must not raise.""" - import subprocess as _subprocess - + def test_mdns_timeout_falls_back_silently(self): + """socket.timeout inside mDNS must not propagate; hostname stays empty.""" host = NmapHost(ip="192.168.1.52", hostname="") with ( patch("app.scanner.dns_lookup.socket.gethostbyaddr", side_effect=OSError), patch( - "app.scanner.dns_lookup.subprocess.run", - side_effect=_subprocess.TimeoutExpired(cmd="avahi-resolve", timeout=2), + "app.scanner.dns_lookup._mdns_ptr_query", + return_value="", # _mdns_ptr_query swallows timeout internally ), ): resolve_hostnames([host]) assert host.hostname == "" @pytest.mark.unit - def test_avahi_not_called_when_ptr_succeeds(self): - """avahi-resolve must not be invoked when PTR already returned a hostname.""" + def test_mdns_not_called_when_ptr_succeeds(self): + """_mdns_ptr_query must not be invoked when PTR already returned a hostname.""" host = NmapHost(ip="192.168.1.1", hostname="") with ( patch( "app.scanner.dns_lookup.socket.gethostbyaddr", return_value=("router.local", [], ["192.168.1.1"]), ), - patch("app.scanner.dns_lookup.subprocess.run") as mock_avahi, + patch("app.scanner.dns_lookup._mdns_ptr_query") as mock_mdns, ): resolve_hostnames([host]) - mock_avahi.assert_not_called() + mock_mdns.assert_not_called() assert host.hostname == "router.local" + + @pytest.mark.unit + def test_mdns_parse_ptr_response(self): + """_parse_mdns_ptr correctly extracts a PTR hostname from a raw DNS response.""" + import struct + + from app.scanner.dns_lookup import _encode_dns_name, _parse_mdns_ptr + + # Build a minimal DNS response with one PTR answer + ptr_name = "50.1.168.192.in-addr.arpa" + answer_name = b"\xc0\x0c" # compression pointer back to question (offset 12) + rdata = _encode_dns_name("pi.local") + rdlength = len(rdata) + + # Header: ID=0, QR=1|AA=1 flags, QDCOUNT=1, ANCOUNT=1 + header = struct.pack("!HHHHHH", 0, 0x8400, 1, 1, 0, 0) + question = _encode_dns_name(ptr_name) + struct.pack("!HH", 12, 1) + answer = answer_name + struct.pack("!HHIH", 12, 1, 120, rdlength) + rdata + + response = header + question + answer + assert _parse_mdns_ptr(response) == "pi.local" diff --git a/docker/Dockerfile b/docker/Dockerfile index a2b755f..5f9db17 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -41,7 +41,6 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends \ nmap \ arp-scan \ - avahi-utils \ libcap2-bin \ gosu \ && rm -rf /var/lib/apt/lists/* diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 82c7aea..1064188 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -8,6 +8,18 @@ import { NavLink, Outlet } from "react-router-dom"; import { useTheme } from "../hooks"; import { useAppVersion } from "../hooks/useAppVersion"; +/** Copy text to clipboard; works on HTTP as well as HTTPS. */ +function copyViaExecCommand(text: string): void { + const el = document.createElement("textarea"); + el.value = text; + el.style.position = "fixed"; + el.style.opacity = "0"; + document.body.appendChild(el); + el.select(); + document.execCommand("copy"); + document.body.removeChild(el); +} + const navLinks = [ { to: "/", label: "Dashboard", end: true }, { to: "/devices", label: "Devices", end: false }, @@ -92,10 +104,25 @@ export function Layout() { const handleCopyVersion = () => { if (!version) return; - navigator.clipboard.writeText(`v${version}`).then(() => { + const text = `v${version}`; + const flash = () => { setCopied(true); setTimeout(() => setCopied(false), 1500); - }); + }; + // navigator.clipboard requires a secure context (HTTPS / localhost). + // Fall back to execCommand for plain-HTTP home-lab deployments. + if (navigator.clipboard) { + navigator.clipboard + .writeText(text) + .then(flash) + .catch(() => { + copyViaExecCommand(text); + flash(); + }); + } else { + copyViaExecCommand(text); + flash(); + } }; return ( diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index c03ab01..4cd5c7a 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -6,16 +6,41 @@ import { useState } from "react"; import { Card, PageHeader } from "../components"; import { useAppVersion } from "../hooks/useAppVersion"; +/** Copy text to clipboard; works on HTTP as well as HTTPS. */ +function copyViaExecCommand(text: string): void { + const el = document.createElement("textarea"); + el.value = text; + el.style.position = "fixed"; + el.style.opacity = "0"; + document.body.appendChild(el); + el.select(); + document.execCommand("copy"); + document.body.removeChild(el); +} + export function SettingsPage() { const { version, loading } = useAppVersion(); const [copied, setCopied] = useState(false); const handleCopyVersion = () => { if (!version) return; - navigator.clipboard.writeText(`v${version}`).then(() => { + const text = `v${version}`; + const flash = () => { setCopied(true); setTimeout(() => setCopied(false), 1500); - }); + }; + if (navigator.clipboard) { + navigator.clipboard + .writeText(text) + .then(flash) + .catch(() => { + copyViaExecCommand(text); + flash(); + }); + } else { + copyViaExecCommand(text); + flash(); + } }; return (