""" Parse syslog datagrams from AsusWRT-Merlin's dnsmasq and kernel/iptables. Handles RFC 3164 syslog envelope: Mmm DD HH:MM:SS hostname dnsmasq[PID]: message or with PRI prefix: Mmm DD HH:MM:SS hostname dnsmasq[PID]: message Recognised dnsmasq message formats: query[TYPE] domain from IP — DNS query event reply domain is IP — resolved to an IP reply domain is NXDOMAIN — domain does not exist reply domain is NODATA — exists but no records of requested type DHCPACK(iface) IP MAC [hostname] — DHCP lease acknowledged Recognised kernel/iptables message formats: WAN_IN: IN=eth0 SRC=x.x.x.x DST=y.y.y.y ... PROTO=TCP SPT=N DPT=N ... """ import re import logging from dataclasses import dataclass, field from datetime import datetime from typing import Optional logger = logging.getLogger(__name__) # ── Compiled regexes ────────────────────────────────────────────────────────── # Strip optional RFC 3164 PRI prefix: _RE_PRI = re.compile(r'^<\d+>') # Full syslog line: optional PRI + "Mmm DD HH:MM:SS hostname process[pid]: msg" # We capture the process token and the message body. _RE_SYSLOG = re.compile( r'(?:<\d+>)?' # optional PRI r'\w{3}\s+\d+\s+\d{2}:\d{2}:\d{2}' # timestamp (Mmm DD HH:MM:SS) r'\s+\S+' # hostname r'\s+(dnsmasq(?:-dhcp)?|kernel)(?:\[\d+\])?:' # process (group 1) r'\s*(.*)', # message body (group 2) re.DOTALL, ) # dnsmasq DNS query: query[TYPE] domain from IP _RE_QUERY = re.compile(r'^query\[(\w+)\]\s+([\w.\-]+)\s+from\s+([\d.a-fA-F:]+)') # dnsmasq reply: reply domain is ANSWER # ANSWER may be: an IP address, NXDOMAIN, NODATA, NODATA-IPv4, NODATA-IPv6, etc. _RE_REPLY = re.compile(r'^reply\s+([\w.\-]+)\s+is\s+(\S+)') # dnsmasq DHCP ack: DHCPACK(iface) IP MAC [hostname] _RE_DHCP = re.compile(r'^DHCPACK\(\S+\)\s+([\d.]+)\s+([\da-fA-F:]+)\s*(\S*)') # kernel: iptables log prefix marker _RE_WAN_PREFIX = re.compile(r'WAN_IN:\s+') # Simple IPv4/IPv6 check (enough to distinguish from NXDOMAIN/NODATA strings) _RE_IP = re.compile(r'^[\d.a-fA-F:]+$') # Private IP ranges to skip for geo (same check as nginxmon) import ipaddress _PRIVATE_NETS = ( ipaddress.ip_network('10.0.0.0/8'), ipaddress.ip_network('172.16.0.0/12'), ipaddress.ip_network('192.168.0.0/16'), ipaddress.ip_network('127.0.0.0/8'), ipaddress.ip_network('::1/128'), ipaddress.ip_network('fc00::/7'), ) def _is_public_ip(addr: str) -> bool: try: ip = ipaddress.ip_address(addr) return not any(ip in net for net in _PRIVATE_NETS) except ValueError: return False def _parse_kv(text: str) -> dict: """Parse KEY=value pairs from an iptables log line.""" return dict(re.findall(r'(\w+)=([^\s]+)', text)) # ── Result dataclasses ──────────────────────────────────────────────────────── @dataclass class QueryEvent: client_ip: str domain: str query_type: str @dataclass class ReplyEvent: domain: str answer: str # raw answer (IP, NXDOMAIN, NODATA, …) is_nxdomain: bool resolved_ip: Optional[str] # public IP only, or None @dataclass class DhcpEvent: ip: str mac: str hostname: str @dataclass class WanEventParsed: src_ip: str protocol: str # TCP / UDP / ICMP dst_port: Optional[int] src_port: Optional[int] @dataclass class ParseResult: query: Optional[QueryEvent] = None reply: Optional[ReplyEvent] = None dhcp: Optional[DhcpEvent] = None wan_event: Optional[WanEventParsed] = None # ── Public API ──────────────────────────────────────────────────────────────── def parse_line(raw: str) -> Optional[ParseResult]: """ Parse a single syslog datagram string. Returns a ParseResult if a recognised event was found, else None. """ raw = raw.strip() if not raw: return None m = _RE_SYSLOG.match(raw) if not m: # Some Merlin builds omit the standard syslog prefix; try matching # dnsmasq message body directly. process = None body = raw else: process = m.group(1) body = m.group(2).strip() # ── Kernel / iptables WAN events ───────────────────────────────────────── if process == 'kernel' or (process is None and _RE_WAN_PREFIX.search(raw)): # Strip optional kernel timestamp "[12345.678] " body_stripped = re.sub(r'^\[\d+\.\d+\]\s*', '', body) if _RE_WAN_PREFIX.search(body_stripped): return _parse_wan_event(body_stripped) return None # ── dnsmasq events ─────────────────────────────────────────────────────── # DNS query mq = _RE_QUERY.match(body) if mq: return ParseResult(query=QueryEvent( client_ip=mq.group(3), domain=mq.group(2).rstrip('.'), query_type=mq.group(1), )) # DNS reply mr = _RE_REPLY.match(body) if mr: domain = mr.group(1).rstrip('.') answer = mr.group(2) is_nxdomain = answer.upper() in ('NXDOMAIN', 'NODATA', 'NODATA-IPV4', 'NODATA-IPV6') resolved_ip = None if not is_nxdomain and _RE_IP.match(answer) and _is_public_ip(answer): resolved_ip = answer return ParseResult(reply=ReplyEvent( domain=domain, answer=answer, is_nxdomain=is_nxdomain, resolved_ip=resolved_ip, )) # DHCP ack md = _RE_DHCP.match(body) if md: return ParseResult(dhcp=DhcpEvent( ip=md.group(1), mac=md.group(2).lower(), hostname=md.group(3), )) return None def _parse_wan_event(body: str) -> Optional[ParseResult]: """Parse an iptables WAN_IN log line body into a WanEventParsed.""" kv = _parse_kv(body) src_ip = kv.get('SRC', '') if not src_ip or not _is_public_ip(src_ip): return None protocol = kv.get('PROTO', 'UNKNOWN').upper() try: dst_port = int(kv['DPT']) if 'DPT' in kv else None except (ValueError, KeyError): dst_port = None try: src_port = int(kv['SPT']) if 'SPT' in kv else None except (ValueError, KeyError): src_port = None return ParseResult(wan_event=WanEventParsed( src_ip=src_ip, protocol=protocol, dst_port=dst_port, src_port=src_port, ))