Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 119 additions & 22 deletions backend/app/scanner/dns_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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: "<ip>\t<hostname>"
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


Expand Down
61 changes: 40 additions & 21 deletions backend/tests/test_enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
1 change: 0 additions & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
Expand Down
31 changes: 29 additions & 2 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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 (
Expand Down
29 changes: 27 additions & 2 deletions frontend/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Loading