mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
- 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>
120 lines
4.5 KiB
Python
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"})'
|