Files
junvandCopilot 430ca18b41 routermon: add WAN incoming traffic monitoring
- Add WanEvent model (src_ip, protocol, dst_port, src_port, geo fields)
  with migration 0003_wanevent

- Extend parser to handle kernel:/iptables WAN_IN: syslog lines
  - Generalise _RE_SYSLOG to accept 'kernel' process name (no pid)
  - Parse KEY=value tokens from iptables log (robust vs monolithic regex)
  - Reject private source IPs silently

- Receiver: bulk-create WanEvent rows in _flush(); replace per-flush
  geo threads with a single bounded geo-enrichment worker (_geo_queue,
  max 500) to safely handle high-volume port scans

- Tasks: batch-delete WanEvent rows (<=3 day retention cap); batch-delete
  DnsQuery rows to avoid long SQLite locks

- Views: WanLivePartialView (filterable HTMX table), WanChartDataView
  (timeline JSON); dashboard context adds wan_total_24h,
  top_attacked_ports_json, top_wan_sources

- Templates:
  - _live_wan.html: live event table with color-coded protocol,
    clickable IP/port filters, well-known port labels
  - dashboard.html: WAN section with 24h counter, timeline chart,
    top attacked ports bar chart, top source IPs table, live event stream
  - settings.html: Step 5 guide for /jffs/scripts/firewall-start with
    rate-limited iptables LOG rules (INPUT + FORWARD chains, 60/min limit)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 15:34:39 +10:00

212 lines
6.8 KiB
Python

"""
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: <N>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: <N>
_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,
))