Files
links/routermon/models.py
T
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

120 lines
4.5 KiB
Python

from django.db import models
class RouterMonSettings(models.Model):
"""Singleton (pk=1) — router syslog receiver configuration."""
enabled = models.BooleanField(default=False)
syslog_port = models.IntegerField(
default=5514,
help_text=(
'UDP port to listen on for syslog datagrams. Default: 5514 (non-privileged). '
'If your router only supports port 514, add an iptables redirect on the k3s node: '
'iptables -t nat -A PREROUTING -p udp --dport 514 -j REDIRECT --to-port 5514'
),
)
excluded_clients = models.TextField(
blank=True,
default='',
help_text='One LAN IP per line. DNS queries from these clients will not be stored.',
)
retention_days = models.IntegerField(
default=7,
help_text='Delete DNS query records older than this many days.',
)
last_received_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = 'Router Monitor Settings'
verbose_name_plural = 'Router Monitor Settings'
def __str__(self):
status = 'enabled' if self.enabled else 'disabled'
return f'RouterMon Settings ({status}, port {self.syslog_port})'
@classmethod
def get(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
class DnsQuery(models.Model):
"""One DNS query event received from the router's dnsmasq syslog."""
timestamp = models.DateTimeField(db_index=True)
client_ip = models.GenericIPAddressField(db_index=True)
client_name = models.CharField(max_length=200, blank=True, help_text='Hostname from DHCP lease at query time')
domain = models.CharField(max_length=500, db_index=True)
query_type = models.CharField(max_length=20, default='A')
is_nxdomain = models.BooleanField(default=False, db_index=True)
# Best-effort: first public A/AAAA record from the corresponding reply line.
resolved_ip = models.GenericIPAddressField(null=True, blank=True)
# Geo for resolved_ip (populated asynchronously)
country = models.CharField(max_length=100, blank=True)
country_code = models.CharField(max_length=10, blank=True)
city = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['-timestamp']
indexes = [
models.Index(fields=['client_ip', 'timestamp']),
models.Index(fields=['domain', 'timestamp']),
models.Index(fields=['timestamp', 'is_nxdomain']),
]
def __str__(self):
flag = ' [NX]' if self.is_nxdomain else ''
return f'{self.client_ip}{self.domain} ({self.query_type}){flag} @ {self.timestamp:%Y-%m-%d %H:%M:%S}'
@property
def geo_display(self):
parts = [p for p in [self.city, self.country] if p]
return ', '.join(parts) if parts else '—'
class WanEvent(models.Model):
"""One incoming WAN connection attempt logged by iptables (WAN_IN: prefix)."""
timestamp = models.DateTimeField(db_index=True)
src_ip = models.GenericIPAddressField(db_index=True)
dst_port = models.IntegerField(null=True, blank=True, db_index=True)
protocol = models.CharField(max_length=10) # TCP / UDP / ICMP
src_port = models.IntegerField(null=True, blank=True)
# Geo for src_ip (populated asynchronously)
country = models.CharField(max_length=100, blank=True)
country_code = models.CharField(max_length=10, blank=True)
city = models.CharField(max_length=100, blank=True)
isp = models.CharField(max_length=200, blank=True)
class Meta:
ordering = ['-timestamp']
indexes = [
models.Index(fields=['src_ip', 'timestamp']),
models.Index(fields=['dst_port', 'timestamp']),
models.Index(fields=['timestamp', 'protocol']),
]
def __str__(self):
port = f':{self.dst_port}' if self.dst_port else ''
return f'{self.src_ip}{self.protocol}{port} @ {self.timestamp:%Y-%m-%d %H:%M:%S}'
@property
def geo_display(self):
parts = [p for p in [self.city, self.country] if p]
return ', '.join(parts) if parts else '—'
class DhcpLease(models.Model):
"""Latest-known DHCP lease: maps LAN IP to MAC + hostname."""
ip = models.GenericIPAddressField(unique=True, db_index=True)
mac = models.CharField(max_length=17, blank=True)
hostname = models.CharField(max_length=200, blank=True)
last_seen = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['ip']
def __str__(self):
return f'{self.ip} ({self.hostname or self.mac or "unknown"})'