""" UDP syslog receiver for routermon. Architecture: - One background thread owns the UDP socket and enqueues parsed events. - A separate writer thread drains the queue and bulk-saves to the DB every FLUSH_INTERVAL seconds (or when the queue reaches FLUSH_SIZE). - A single geo-enrichment worker drains a bounded queue so WAN scan volumes don't create unbounded thread churn. - Only one Gunicorn worker binds the socket (EADDRINUSE is silently ignored in the others — matches the project's existing in-process APScheduler style). dnsmasq reply correlation: - A small in-memory dict `_pending` maps `domain` → (pk, timestamp) for outstanding query rows awaiting a reply. Entries expire after REPLY_TTL seconds so stale pending records don't accumulate. """ import errno import logging import queue import socket import threading import time from datetime import timedelta from django.utils import timezone logger = logging.getLogger(__name__) FLUSH_INTERVAL = 2 # seconds between writer flushes FLUSH_SIZE = 100 # flush immediately when queue reaches this size REPLY_TTL = 10 # seconds to keep a query in _pending for reply matching MAX_QUEUE = 10_000 # drop oldest items if queue exceeds this (backpressure) MAX_GEO_QUEUE = 500 # bounded geo-enrichment queue (drop excess rather than thread-spawn) _receiver_thread: threading.Thread | None = None _writer_thread: threading.Thread | None = None _geo_thread: threading.Thread | None = None _sock: socket.socket | None = None _queue: queue.Queue = queue.Queue(maxsize=MAX_QUEUE) _geo_queue: queue.Queue = queue.Queue(maxsize=MAX_GEO_QUEUE) # in-memory pending dict: domain → (query_pk, created_at) _pending: dict = {} _pending_lock = threading.Lock() # ── Public API ──────────────────────────────────────────────────────────────── def start_receiver(port: int): """ Attempt to start the UDP receiver on `port`. Safe to call from every Gunicorn worker — only the first to bind succeeds; others silently skip. """ global _receiver_thread, _writer_thread, _geo_thread, _sock if _receiver_thread and _receiver_thread.is_alive(): return # already running in this process try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # No SO_REUSEADDR: we intentionally want only one worker to bind. # Other workers receive EADDRINUSE and skip cleanly. sock.bind(('0.0.0.0', port)) sock.settimeout(1.0) _sock = sock print(f'routermon: UDP syslog receiver bound on port {port}', flush=True) logger.info('routermon: UDP syslog receiver bound on port %d', port) except OSError as exc: if exc.errno == errno.EADDRINUSE: logger.debug('routermon: port %d already bound (another worker), skipping', port) else: import sys print(f'routermon: failed to bind UDP port {port}: {exc}', file=sys.stderr, flush=True) logger.error('routermon: failed to bind UDP port %d: %s', port, exc) return _geo_thread = threading.Thread(target=_geo_worker_loop, daemon=True, name='routermon-geo') _geo_thread.start() _writer_thread = threading.Thread(target=_writer_loop, daemon=True, name='routermon-writer') _writer_thread.start() _receiver_thread = threading.Thread( target=_receiver_loop, args=(sock,), daemon=True, name='routermon-receiver' ) _receiver_thread.start() def stop_receiver(): global _sock if _sock: try: _sock.close() except Exception: pass _sock = None # ── Socket receiver loop ────────────────────────────────────────────────────── def _receiver_loop(sock: socket.socket): from .parser import parse_line logger.info('routermon: receiver loop started') while True: try: data, _ = sock.recvfrom(4096) except socket.timeout: continue except OSError: # Socket closed (shutdown) break try: line = data.decode('utf-8', errors='replace').rstrip('\n\r') result = parse_line(line) if result is not None: _enqueue(result) except Exception as exc: logger.debug('routermon: parse error: %s', exc) logger.info('routermon: receiver loop stopped') def _enqueue(result): try: _queue.put_nowait(result) except queue.Full: # Drop oldest item to make room (keep most recent) try: _queue.get_nowait() _queue.put_nowait(result) except Exception: pass # ── Writer loop ─────────────────────────────────────────────────────────────── def _writer_loop(): logger.info('routermon: writer loop started') batch = [] last_flush = time.monotonic() while True: # Collect items until flush threshold or timeout deadline = last_flush + FLUSH_INTERVAL while time.monotonic() < deadline and len(batch) < FLUSH_SIZE: try: item = _queue.get(timeout=max(0.1, deadline - time.monotonic())) batch.append(item) except queue.Empty: break if batch: try: _flush(batch) except Exception as exc: logger.error('routermon: flush error: %s', exc, exc_info=True) batch = [] last_flush = time.monotonic() logger.info('routermon: writer loop stopped') def _flush(batch: list): """Persist a batch of ParseResult objects to the database.""" from .models import RouterMonSettings, DnsQuery, DhcpLease, WanEvent settings = RouterMonSettings.get() if not settings.enabled: return excluded = _build_excluded_set(settings.excluded_clients) now = timezone.now() # --- DHCP leases (upsert immediately) --- dhcp_events = [r.dhcp for r in batch if r.dhcp] for ev in dhcp_events: DhcpLease.objects.update_or_create( ip=ev.ip, defaults={'mac': ev.mac, 'hostname': ev.hostname}, ) # --- DNS queries --- hostname_cache = { lease.ip: lease.hostname for lease in DhcpLease.objects.filter( ip__in={r.query.client_ip for r in batch if r.query} ) } query_objs = [] for r in batch: if not r.query: continue q = r.query if q.client_ip in excluded: continue obj = DnsQuery( timestamp=now, client_ip=q.client_ip, client_name=hostname_cache.get(q.client_ip, ''), domain=q.domain, query_type=q.query_type, ) query_objs.append(obj) if query_objs: created = DnsQuery.objects.bulk_create(query_objs) # Register created queries in pending dict for reply correlation with _pending_lock: for obj in created: _pending[obj.domain] = (obj.pk, now) # --- DNS replies: correlate with pending queries --- reply_events = [r.reply for r in batch if r.reply] if reply_events: _apply_replies(reply_events, now) # Expire old pending entries _expire_pending(now) # --- WAN events --- wan_events = [r.wan_event for r in batch if r.wan_event] if wan_events: wan_objs = [ WanEvent( timestamp=now, src_ip=ev.src_ip, protocol=ev.protocol, dst_port=ev.dst_port, src_port=ev.src_port, ) for ev in wan_events ] WanEvent.objects.bulk_create(wan_objs) # Enqueue unique source IPs for geo enrichment (non-blocking). # Skip IPs already known to have no geo data to reduce queue churn. seen_ips = set() for ev in wan_events: if ev.src_ip not in seen_ips and ev.src_ip not in _geo_skip: seen_ips.add(ev.src_ip) try: _geo_queue.put_nowait(('wan', ev.src_ip)) except queue.Full: pass # drop silently under high scan volume # Touch last_received_at once per flush (coalesced) RouterMonSettings.objects.filter(pk=1).update(last_received_at=now) # Enqueue DNS geo enrichment (deduplicated by IP) pks_needing_geo = list( DnsQuery.objects.filter( resolved_ip__isnull=False, country='', timestamp__gte=now - timedelta(minutes=5) ).values_list('pk', flat=True)[:200] ) for pk in pks_needing_geo: try: _geo_queue.put_nowait(('dns', pk)) except queue.Full: break def _apply_replies(reply_events, now): from .models import DnsQuery with _pending_lock: for ev in reply_events: entry = _pending.pop(ev.domain, None) if entry is None: continue pk, _ = entry updates = {} if ev.is_nxdomain: updates['is_nxdomain'] = True if ev.resolved_ip: updates['resolved_ip'] = ev.resolved_ip if updates: DnsQuery.objects.filter(pk=pk).update(**updates) def _expire_pending(now): cutoff = now - timedelta(seconds=REPLY_TTL) with _pending_lock: expired = [domain for domain, (_, ts) in _pending.items() if ts < cutoff] for domain in expired: del _pending[domain] # ── Geo enrichment worker ───────────────────────────────────────────────────── # In-memory set of IPs that returned no geo data — avoids re-queuing them. # Cleared when it grows too large to prevent unbounded memory use. _geo_skip: set[str] = set() _GEO_SKIP_MAX = 10_000 # Circuit breaker: if ip-api.com returns empty for N+ consecutive large chunks, # back off for GEO_BACKOFF_SECS before trying again. _geo_consecutive_misses: int = 0 _geo_backoff_until: float = 0.0 _GEO_MISS_THRESHOLD = 2 # consecutive all-empty chunks before backoff _GEO_BACKOFF_SECS = 300.0 # 5-minute backoff when unreachable def _geo_worker_loop(): """ Batch geo enrichment worker. Drains up to 200 queue items per cycle, deduplicates IPs, performs a single bulk DB cache check, then calls the ip-api.com batch endpoint (100 IPs per request) only for IPs that are truly missing from the cache. Failed IPs get an empty IPGeoCache placeholder saved to DB so they are never looked up again. Circuit breaker: after _GEO_MISS_THRESHOLD consecutive all-empty API responses, all remaining missing IPs are saved as empty placeholders and API calls are suppressed for _GEO_BACKOFF_SECS (5 min) to avoid a timeout-storm when ip-api.com is unreachable. """ global _geo_consecutive_misses, _geo_backoff_until logger.info('routermon: geo worker started') from nginxmon.geo import _lookup_batch from nginxmon.models import IPGeoCache from routermon.models import DnsQuery, WanEvent while True: # Block until at least one item arrives. try: first = _geo_queue.get(timeout=5) except queue.Empty: continue # Non-blocking drain — collect up to 200 items before processing. batch = [first] while len(batch) < 200: try: batch.append(_geo_queue.get_nowait()) except queue.Empty: break dns_pks = [v for k, v in batch if k == 'dns'] wan_ips = list({v for k, v in batch if k == 'wan'}) # deduplicated # Resolve DNS pks → resolved_ip in one query. dns_pk_ip: dict[int, str] = {} if dns_pks: for row in DnsQuery.objects.filter( pk__in=dns_pks, country='' ).values('pk', 'resolved_ip'): if row['resolved_ip']: dns_pk_ip[row['pk']] = row['resolved_ip'] all_ips: set[str] = set(dns_pk_ip.values()) | set(wan_ips) if not all_ips: continue try: # Bulk DB cache check — one query for all IPs. geo_cache: dict[str, IPGeoCache] = { c.ip: c for c in IPGeoCache.objects.filter(ip__in=all_ips) } # Only fetch IPs not already in the DB (even empty entries count as "done"). missing = [ ip for ip in all_ips if ip not in geo_cache and ip not in _geo_skip ] if missing: # Circuit breaker: if ip-api.com has been unresponsive, skip API # calls and immediately cache all missing IPs as empty so they # are not retried until the backoff expires. in_backoff = time.monotonic() < _geo_backoff_until if in_backoff: IPGeoCache.objects.bulk_create( [IPGeoCache(ip=ip) for ip in missing], ignore_conflicts=True, ) _geo_skip.update(missing) if len(_geo_skip) > _GEO_SKIP_MAX: _geo_skip.clear() else: for i in range(0, len(missing), 100): chunk = missing[i:i + 100] results = _lookup_batch(chunk) # Upsert successful lookups. for ip, data in results.items(): obj, _ = IPGeoCache.objects.get_or_create(ip=ip) obj.country = data.get('country', '') obj.country_code = data.get('countryCode', '') obj.region = data.get('regionName', '') obj.city = data.get('city', '') obj.lat = data.get('lat') obj.lon = data.get('lon') obj.isp = data.get('isp', '') obj.save() geo_cache[ip] = obj # Save empty placeholder for every IP that failed — prevents # future DB misses and stops the API from being retried. failed = set(chunk) - set(results) if failed: IPGeoCache.objects.bulk_create( [IPGeoCache(ip=ip) for ip in failed], ignore_conflicts=True, ) _geo_skip.update(failed) if len(_geo_skip) > _GEO_SKIP_MAX: _geo_skip.clear() # Circuit-breaker tracking: a large chunk with zero results # almost certainly means the API is unreachable. if len(chunk) >= 5 and not results: _geo_consecutive_misses += 1 if _geo_consecutive_misses >= _GEO_MISS_THRESHOLD: _geo_backoff_until = time.monotonic() + _GEO_BACKOFF_SECS logger.warning( 'routermon: ip-api.com unreachable (%d consecutive misses) — ' 'backing off for %.0fs', _geo_consecutive_misses, _GEO_BACKOFF_SECS, ) _geo_consecutive_misses = 0 # Save remaining uncached IPs and stop the loop early. remaining = missing[i + 100:] if remaining: IPGeoCache.objects.bulk_create( [IPGeoCache(ip=ip) for ip in remaining if ip not in geo_cache], ignore_conflicts=True, ) _geo_skip.update(remaining) break elif results: _geo_consecutive_misses = 0 # Apply geo data to DNS records. for pk, ip in dns_pk_ip.items(): geo = geo_cache.get(ip) if geo and geo.country: DnsQuery.objects.filter(pk=pk, country='').update( country=geo.country, country_code=geo.country_code, city=geo.city, ) # Apply geo data to WAN records (bulk-update per unique IP). for ip in set(wan_ips): geo = geo_cache.get(ip) if geo and geo.country: WanEvent.objects.filter(src_ip=ip, country='').update( country=geo.country, country_code=geo.country_code, city=geo.city, isp=getattr(geo, 'isp', ''), ) except Exception as exc: logger.warning('routermon: geo batch error: %s', exc) def _build_excluded_set(text: str) -> set: result = set() for line in (text or '').splitlines(): entry = line.strip() if entry and not entry.startswith('#'): result.add(entry) return result