diff --git a/decnet/geoip/ptr.py b/decnet/geoip/ptr.py new file mode 100644 index 00000000..b5af6c28 --- /dev/null +++ b/decnet/geoip/ptr.py @@ -0,0 +1,87 @@ +"""Reverse DNS (PTR record) lookup for attacker IPs. + +Colocated with ``decnet.geoip`` because the shape matches: take an IP, +return a piece of supplementary metadata, never raise. Same operator +posture as ``enrich_ip`` — a missing PTR must never break profile +building. + +The profiler calls this once per attacker IP at first sighting. Never +re-resolves — the profiler tracks already-attempted IPs in-memory +(``_WorkerState.ptr_attempted``) so a persistent NXDOMAIN doesn't burn +2 seconds of tick time on every cycle. +""" +from __future__ import annotations + +import asyncio +import ipaddress +import os +import socket +from typing import Optional + +from decnet.logging import get_logger + +log = get_logger("geoip.ptr") + + +_DEFAULT_TIMEOUT = 2.0 + + +def _is_resolvable(ip: str) -> bool: + """True iff ``ip`` is a parseable public address worth querying. + + Private / loopback / link-local / multicast / reserved addresses + have no meaningful PTR at the public resolver level, so short- + circuit before spending a DNS round-trip on them. + """ + try: + addr = ipaddress.ip_address(ip) + except (ValueError, TypeError): + return False + if addr.is_loopback or addr.is_private or addr.is_link_local: + return False + if addr.is_multicast or addr.is_reserved or addr.is_unspecified: + return False + return True + + +def _blocking_lookup(ip: str) -> Optional[str]: + """Synchronous PTR lookup — runs in the executor thread.""" + try: + hostname, _aliases, _addrs = socket.gethostbyaddr(ip) + return hostname or None + except (socket.herror, socket.gaierror, OSError): + return None + + +async def resolve_ptr_record( + ip: str, + *, + timeout: float = _DEFAULT_TIMEOUT, +) -> Optional[str]: + """Resolve *ip* to a PTR / rDNS hostname. + + Returns the canonical hostname on success, ``None`` on any failure + (NXDOMAIN, timeout, malformed input, env kill-switch). Never raises + — PTR is supplementary attacker metadata; a missing lookup must not + break profile building. + + Honours ``DECNET_PTR_ENABLED=false`` for locked-down environments + where egress DNS is forbidden. + """ + if os.environ.get("DECNET_PTR_ENABLED", "true").lower() == "false": + return None + if not _is_resolvable(ip): + return None + + loop = asyncio.get_running_loop() + try: + return await asyncio.wait_for( + loop.run_in_executor(None, _blocking_lookup, ip), + timeout=timeout, + ) + except asyncio.TimeoutError: + log.debug("ptr: timeout resolving %s after %.1fs", ip, timeout) + return None + except Exception as exc: # noqa: BLE001 — supplementary metadata + log.debug("ptr: resolver crashed for %s: %s", ip, exc) + return None diff --git a/decnet/profiler/worker.py b/decnet/profiler/worker.py index 4da37aa7..89517073 100644 --- a/decnet/profiler/worker.py +++ b/decnet/profiler/worker.py @@ -30,6 +30,7 @@ from decnet.bus.publish import ( from decnet.correlation.engine import CorrelationEngine from decnet.correlation.parser import LogEvent from decnet.geoip import enrich_ip +from decnet.geoip.ptr import resolve_ptr_record from decnet.logging import get_logger from decnet.profiler.behavioral import build_behavior_record from decnet.telemetry import traced as _traced, get_tracer as _get_tracer @@ -76,6 +77,10 @@ class _WorkerState: # Optional bus hook — fires ``("scored", payload)`` per profile upsert. # None when the bus is disabled or unreachable. publish_attacker: Callable[[str, dict[str, Any]], None] | None = None + # Set of IPs we've already tried to PTR-resolve in this worker's + # lifetime. Bounds retry to once per worker boot so a persistently + # NXDOMAIN-returning IP doesn't burn 2s of tick time on every cycle. + ptr_attempted: set[str] = field(default_factory=set) async def attacker_profile_worker(repo: BaseRepository, *, interval: int = 30) -> None: @@ -178,6 +183,28 @@ async def _incremental_update(repo: BaseRepository, state: _WorkerState) -> None logger.info("attacker worker: updated %d profiles (incremental)", len(affected_ips)) +_PTR_CONCURRENCY = 10 + + +async def _resolve_ptrs_for(ips: list[str]) -> dict[str, Any]: + """Resolve PTR for each *ip* concurrently, bounded. + + Returns ``{ip: ptr_or_None}`` for every input. Uses an asyncio + semaphore to cap parallel lookups — cold-start could see hundreds + of fresh IPs and we don't want to hammer the OS resolver. + """ + if not ips: + return {} + sem = asyncio.Semaphore(_PTR_CONCURRENCY) + + async def _one(ip: str) -> tuple[str, Any]: + async with sem: + return ip, await resolve_ptr_record(ip) + + results = await asyncio.gather(*(_one(ip) for ip in ips)) + return dict(results) + + @_traced("profiler.update_profiles") async def _update_profiles( repo: BaseRepository, @@ -187,6 +214,14 @@ async def _update_profiles( traversal_map = {t.attacker_ip: t for t in state.engine.traversals(min_deckies=2)} bounties_map = await repo.get_bounties_for_ips(ips) + # PTR resolution: one shot per IP per worker lifetime. OS resolver + # caches, so re-runs on worker restart hit cache instantly for IPs + # resolved recently; only never-seen addresses pay the 2s ceiling. + fresh = [ip for ip in ips if ip not in state.ptr_attempted] + for ip in fresh: + state.ptr_attempted.add(ip) + ptrs = await _resolve_ptrs_for(fresh) + _tracer = _get_tracer("profiler") for ip in ips: events = state.engine._events.get(ip, []) @@ -201,7 +236,15 @@ async def _update_profiles( bounties = bounties_map.get(ip, []) commands = _extract_commands_from_events(events) - record = _build_record(ip, events, traversal, bounties, commands) + if ip in ptrs: + record = _build_record( + ip, events, traversal, bounties, commands, + ptr_record=ptrs[ip], + ) + else: + # Not in ptrs → already attempted in a prior cycle → skip + # kwarg so upsert preserves whatever's stored. + record = _build_record(ip, events, traversal, bounties, commands) attacker_uuid = await repo.upsert_attacker(record) _span.set_attribute("is_traversal", traversal is not None) @@ -243,12 +286,17 @@ async def _update_profiles( logger.error("attacker worker: smtp target upsert failed for %s: %s", ip, exc) +_UNSET = object() # sentinel — distinguishes "not passed" from "None" + + def _build_record( ip: str, events: list[LogEvent], traversal: Any, bounties: list[dict[str, Any]], commands: list[dict[str, Any]], + *, + ptr_record: Any = _UNSET, ) -> dict[str, Any]: services = sorted({e.service for e in events}) deckies = ( @@ -260,7 +308,7 @@ def _build_record( credential_count = sum(1 for b in bounties if b.get("bounty_type") == "credential") country_code, country_source = enrich_ip(ip) - return { + record: dict[str, Any] = { "ip": ip, "first_seen": min(e.timestamp for e in events), "last_seen": max(e.timestamp for e in events), @@ -279,6 +327,13 @@ def _build_record( "country_source": country_source, "updated_at": datetime.now(timezone.utc), } + # ptr_record is omitted from the dict entirely when the caller didn't + # supply one — lets the upsert's attribute-merge preserve any value + # already stored on the row without us having to think about "None + # means preserve vs. overwrite". + if ptr_record is not _UNSET: + record["ptr_record"] = ptr_record + return record def _first_contact_deckies(events: list[LogEvent]) -> list[str]: diff --git a/decnet/web/db/models/attackers.py b/decnet/web/db/models/attackers.py index 5c1ad913..a6583d29 100644 --- a/decnet/web/db/models/attackers.py +++ b/decnet/web/db/models/attackers.py @@ -63,6 +63,11 @@ class Attacker(SQLModel, table=True): # Nullable because private / loopback / IPv6 sources never resolve. country_code: Optional[str] = Field(default=None, max_length=2, index=True) country_source: Optional[str] = Field(default=None, max_length=16) + # Reverse-DNS (PTR) name, one-shot resolved by the profiler at first + # sighting. Nullable — many attackers run infra with no rDNS, and + # private/loopback addresses never resolve. 256 chars matches + # RFC 1035 max hostname length. + ptr_record: Optional[str] = Field(default=None, max_length=256) updated_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), index=True ) diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 70867eed..33253d55 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -61,6 +61,7 @@ interface AttackerData { commands: { service: string; decky: string; command: string; timestamp: string }[]; country_code: string | null; country_source: string | null; + ptr_record: string | null; updated_at: string; behavior: AttackerBehavior | null; service_activity?: { @@ -1012,6 +1013,20 @@ const AttackerDetail: React.FC = () => { unknown )} +