mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Add router monitor
This commit is contained in:
@@ -70,3 +70,23 @@ class CoreConfig(AppConfig):
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info("Scheduled periodic task: flush_click_buffer (every 60s)")
|
||||
|
||||
# ── routermon ──────────────────────────────────────────────────────────
|
||||
try:
|
||||
from routermon.tasks import cleanup_old_queries
|
||||
from routermon.models import RouterMonSettings
|
||||
from routermon.receiver import start_receiver
|
||||
|
||||
rm_settings = RouterMonSettings.get()
|
||||
if rm_settings.enabled:
|
||||
start_receiver(rm_settings.syslog_port)
|
||||
|
||||
scheduler.add_job(
|
||||
cleanup_old_queries,
|
||||
trigger=IntervalTrigger(hours=6),
|
||||
id='routermon_cleanup',
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info("routermon: scheduled cleanup job (every 6h)")
|
||||
except Exception as exc:
|
||||
logger.warning("routermon: startup error (non-fatal): %s", exc)
|
||||
|
||||
@@ -22,6 +22,7 @@ INSTALLED_APPS = [
|
||||
'markdown', # 只需要基本的markdown包
|
||||
'netscan',
|
||||
'nginxmon',
|
||||
'routermon',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'core.urls'
|
||||
|
||||
@@ -49,6 +49,7 @@ urlpatterns = [
|
||||
# Include netscan and files BEFORE links.urls to prevent the alias catch-all from intercepting them
|
||||
path('ui/netscan/', include('netscan.urls')),
|
||||
path('ui/nginxmon/', include('nginxmon.urls')),
|
||||
path('ui/routermon/', include('routermon.urls')),
|
||||
path('ui/files/', include('links.file_urls')),
|
||||
|
||||
# Import external image by URL — /import/images/<path:image_url> (also plural alias)
|
||||
|
||||
@@ -175,6 +175,10 @@ spec:
|
||||
- containerPort: 8000
|
||||
name: links-port
|
||||
protocol: TCP
|
||||
- containerPort: 5514
|
||||
name: syslog-udp
|
||||
protocol: UDP
|
||||
hostPort: 5514
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
|
||||
@@ -175,6 +175,10 @@ spec:
|
||||
- containerPort: 8000
|
||||
name: links-port
|
||||
protocol: TCP
|
||||
- containerPort: 5514
|
||||
name: syslog-udp
|
||||
protocol: UDP
|
||||
hostPort: 5514
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.contrib import admin
|
||||
from .models import RouterMonSettings, DnsQuery, DhcpLease
|
||||
|
||||
|
||||
@admin.register(RouterMonSettings)
|
||||
class RouterMonSettingsAdmin(admin.ModelAdmin):
|
||||
list_display = ['syslog_port', 'enabled', 'retention_days', 'last_received_at']
|
||||
|
||||
|
||||
@admin.register(DnsQuery)
|
||||
class DnsQueryAdmin(admin.ModelAdmin):
|
||||
list_display = ['timestamp', 'client_ip', 'client_name', 'domain', 'query_type', 'is_nxdomain', 'resolved_ip', 'country']
|
||||
list_filter = ['is_nxdomain', 'query_type']
|
||||
search_fields = ['domain', 'client_ip', 'client_name']
|
||||
ordering = ['-timestamp']
|
||||
|
||||
|
||||
@admin.register(DhcpLease)
|
||||
class DhcpLeaseAdmin(admin.ModelAdmin):
|
||||
list_display = ['ip', 'mac', 'hostname', 'last_seen']
|
||||
search_fields = ['ip', 'mac', 'hostname']
|
||||
ordering = ['-last_seen']
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.apps import AppConfig
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RouterMonConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'routermon'
|
||||
verbose_name = 'Router Monitor'
|
||||
@@ -0,0 +1,20 @@
|
||||
from django import forms
|
||||
from .models import RouterMonSettings
|
||||
|
||||
|
||||
class RouterMonSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = RouterMonSettings
|
||||
fields = ['enabled', 'syslog_port', 'excluded_clients', 'retention_days']
|
||||
widgets = {
|
||||
'excluded_clients': forms.Textarea(attrs={'rows': 4, 'class': 'font-mono text-sm'}),
|
||||
}
|
||||
help_texts = {
|
||||
'syslog_port': (
|
||||
'UDP port the server listens on (default 5514). '
|
||||
'Configure your router to send syslog here. '
|
||||
'If your router only supports port 514, add an iptables redirect on the k3s node.'
|
||||
),
|
||||
'excluded_clients': 'One LAN IP per line. Queries from these devices will be ignored.',
|
||||
'retention_days': 'DNS query log records older than this will be automatically deleted.',
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-16 02:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DhcpLease',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ip', models.GenericIPAddressField(db_index=True, unique=True)),
|
||||
('mac', models.CharField(blank=True, max_length=17)),
|
||||
('hostname', models.CharField(blank=True, max_length=200)),
|
||||
('last_seen', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['ip'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RouterMonSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('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(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Router Monitor Settings',
|
||||
'verbose_name_plural': 'Router Monitor Settings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DnsQuery',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('timestamp', models.DateTimeField(db_index=True)),
|
||||
('client_ip', models.GenericIPAddressField(db_index=True)),
|
||||
('client_name', models.CharField(blank=True, help_text='Hostname from DHCP lease at query time', max_length=200)),
|
||||
('domain', models.CharField(db_index=True, max_length=500)),
|
||||
('query_type', models.CharField(default='A', max_length=20)),
|
||||
('is_nxdomain', models.BooleanField(db_index=True, default=False)),
|
||||
('resolved_ip', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('country', models.CharField(blank=True, max_length=100)),
|
||||
('country_code', models.CharField(blank=True, max_length=10)),
|
||||
('city', models.CharField(blank=True, max_length=100)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-timestamp'],
|
||||
'indexes': [models.Index(fields=['client_ip', 'timestamp'], name='routermon_d_client__2d31fb_idx'), models.Index(fields=['domain', 'timestamp'], name='routermon_d_domain_ca0a82_idx'), models.Index(fields=['timestamp', 'is_nxdomain'], name='routermon_d_timesta_74c854_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class RouterMonSettings(models.Model):
|
||||
"""Singleton (pk=1) — router syslog receiver configuration."""
|
||||
|
||||
enabled = models.BooleanField(default=True)
|
||||
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 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"})'
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Parse syslog datagrams from AsusWRT-Merlin's dnsmasq.
|
||||
|
||||
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 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
|
||||
"""
|
||||
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)?)\[\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*)')
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
# ── 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 ParseResult:
|
||||
query: Optional[QueryEvent] = None
|
||||
reply: Optional[ReplyEvent] = None
|
||||
dhcp: Optional[DhcpEvent] = 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.
|
||||
body = raw
|
||||
else:
|
||||
body = m.group(2).strip()
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
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).
|
||||
- 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)
|
||||
|
||||
_receiver_thread: threading.Thread | None = None
|
||||
_writer_thread: threading.Thread | None = None
|
||||
_sock: socket.socket | None = None
|
||||
_queue: queue.Queue = queue.Queue(maxsize=MAX_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, _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)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(('0.0.0.0', port))
|
||||
sock.settimeout(1.0)
|
||||
_sock = sock
|
||||
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:
|
||||
logger.error('routermon: failed to bind UDP port %d: %s', port, exc)
|
||||
return
|
||||
|
||||
_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
|
||||
|
||||
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)
|
||||
|
||||
# Touch last_received_at once per flush (coalesced)
|
||||
RouterMonSettings.objects.filter(pk=1).update(last_received_at=now)
|
||||
|
||||
# Trigger geo enrichment in background (lazy: only for rows with resolved_ip that have no geo yet)
|
||||
pks_needing_geo = list(
|
||||
DnsQuery.objects.filter(
|
||||
resolved_ip__isnull=False, country='', timestamp__gte=now - timedelta(minutes=5)
|
||||
).values_list('pk', flat=True)[:200]
|
||||
)
|
||||
if pks_needing_geo:
|
||||
threading.Thread(
|
||||
target=_enrich_geo, args=(pks_needing_geo,), daemon=True
|
||||
).start()
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
def _enrich_geo(pks: list[int]):
|
||||
"""Geo-enrich DnsQuery rows by resolved_ip using nginxmon's IPGeoCache."""
|
||||
try:
|
||||
from nginxmon.models import IPGeoCache
|
||||
from routermon.models import DnsQuery
|
||||
import ipaddress
|
||||
|
||||
rows = list(DnsQuery.objects.filter(pk__in=pks).values('pk', 'resolved_ip'))
|
||||
ips = list({r['resolved_ip'] for r in rows if r['resolved_ip']})
|
||||
if not ips:
|
||||
return
|
||||
|
||||
# Fetch missing IPs from ip-api.com
|
||||
cached = {c.ip: c for c in IPGeoCache.objects.filter(ip__in=ips)}
|
||||
missing = [ip for ip in ips if ip not in cached]
|
||||
if missing:
|
||||
from nginxmon.geo import _lookup_batch
|
||||
api_results = _lookup_batch(missing)
|
||||
for ip, data in api_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()
|
||||
cached[ip] = obj
|
||||
|
||||
for row in rows:
|
||||
geo = cached.get(row['resolved_ip'])
|
||||
if geo and geo.country:
|
||||
DnsQuery.objects.filter(pk=row['pk'], country='').update(
|
||||
country=geo.country,
|
||||
country_code=geo.country_code,
|
||||
city=geo.city,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('routermon: geo enrichment 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
|
||||
@@ -0,0 +1,23 @@
|
||||
"""APScheduler periodic tasks for routermon."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cleanup_old_queries():
|
||||
"""Delete DnsQuery rows older than retention_days. Run periodically."""
|
||||
try:
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from .models import RouterMonSettings, DnsQuery
|
||||
|
||||
settings = RouterMonSettings.get()
|
||||
if settings.retention_days <= 0:
|
||||
return
|
||||
|
||||
cutoff = timezone.now() - timedelta(days=settings.retention_days)
|
||||
deleted, _ = DnsQuery.objects.filter(timestamp__lt=cutoff).delete()
|
||||
if deleted:
|
||||
logger.info('routermon: pruned %d old DNS query records (>%d days)', deleted, settings.retention_days)
|
||||
except Exception as exc:
|
||||
logger.error('routermon: cleanup_old_queries error: %s', exc, exc_info=True)
|
||||
@@ -0,0 +1,45 @@
|
||||
<table class="w-full text-xs">
|
||||
<thead class="bg-gray-50 text-gray-500 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Time</th>
|
||||
<th class="px-3 py-2 text-left">Client</th>
|
||||
<th class="px-3 py-2 text-left">Domain</th>
|
||||
<th class="px-3 py-2 text-left">Type</th>
|
||||
<th class="px-3 py-2 text-left">Resolved</th>
|
||||
<th class="px-3 py-2 text-left">Geo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{% for q in queries %}
|
||||
<tr class="hover:bg-gray-50 {% if q.is_nxdomain %}bg-red-50{% endif %}">
|
||||
<td class="px-3 py-2 text-gray-400 whitespace-nowrap font-mono">{{ q.timestamp|date:"H:i:s" }}</td>
|
||||
<td class="px-3 py-2 font-mono">
|
||||
<button class="text-indigo-600 hover:underline"
|
||||
onclick="setQueryFilter('client','{{ q.client_ip|escapejs }}')">{{ q.client_ip }}</button>
|
||||
{% if q.client_name %}<span class="text-gray-400 ml-1">{{ q.client_name }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-gray-800 truncate max-w-[200px]">
|
||||
<button class="text-indigo-600 hover:underline font-mono text-left truncate max-w-full"
|
||||
onclick="setQueryFilter('domain','{{ q.domain|escapejs }}')">{{ q.domain }}</button>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-500">{{ q.query_type }}</td>
|
||||
<td class="px-3 py-2 font-mono">
|
||||
{% if q.is_nxdomain %}
|
||||
<span class="text-red-500 font-medium">NXDOMAIN</span>
|
||||
{% elif q.resolved_ip %}
|
||||
<span class="text-gray-600">{{ q.resolved_ip }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-300">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-500">{{ q.geo_display }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-3 py-8 text-center text-gray-400">
|
||||
No DNS queries yet. Make sure your router is forwarding syslog to this server.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,413 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.6.0/dist/echarts.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js"></script>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<i class="fas fa-router text-indigo-500"></i>
|
||||
Router DNS Monitor
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full
|
||||
{% if settings.enabled %}bg-green-100 text-green-700 animate-pulse{% else %}bg-gray-100 text-gray-500{% endif %}">
|
||||
<span class="w-1.5 h-1.5 rounded-full inline-block {% if settings.enabled %}bg-green-500{% else %}bg-gray-400{% endif %}"></span>
|
||||
{% if settings.enabled %}Live{% else %}Paused{% endif %}
|
||||
</span>
|
||||
</h1>
|
||||
<p class="text-sm text-gray-400 mt-0.5">
|
||||
ASUS GT-AX6000 · dnsmasq syslog · UDP :{{ settings.syslog_port }}
|
||||
</p>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-gray-400">
|
||||
<span id="status-last">{% if settings.last_received_at %}Last: {{ settings.last_received_at|timesince }} ago{% else %}No data yet{% endif %}</span>
|
||||
<span>·</span>
|
||||
<span id="status-total">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<!-- Time range selector -->
|
||||
<div class="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{% for rkey, rlabel in all_ranges %}
|
||||
<a href="?range={{ rkey }}"
|
||||
class="px-2.5 py-1 text-xs rounded-md font-medium transition-colors
|
||||
{% if rkey == current_range %}bg-white text-gray-900 shadow-sm{% else %}text-gray-500 hover:text-gray-700{% endif %}">
|
||||
{{ rlabel|slice:"5:" }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||
<button id="toggle-btn"
|
||||
onclick="toggleReceiver()"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs rounded-md font-medium transition-colors
|
||||
{% if settings.enabled %}bg-green-100 text-green-700 hover:bg-green-200{% else %}bg-gray-200 text-gray-500 hover:bg-gray-300{% endif %}">
|
||||
<span id="toggle-dot" class="w-1.5 h-1.5 rounded-full mr-1.5 {% if settings.enabled %}bg-green-500{% else %}bg-gray-400{% endif %}"></span>
|
||||
<span id="toggle-label">{% if settings.enabled %}Receiving{% else %}Paused{% endif %}</span>
|
||||
</button>
|
||||
<a href="{% url 'routermon-settings' %}"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-gray-100 text-gray-600 text-xs rounded-md hover:bg-gray-200">
|
||||
<i class="fas fa-cog mr-1.5"></i> Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
{% for msg in messages %}
|
||||
<div class="px-4 py-2 rounded-md text-sm border
|
||||
{% if msg.tags == 'success' %}bg-green-50 text-green-800 border-green-200
|
||||
{% elif msg.tags == 'error' %}bg-red-50 text-red-800 border-red-200
|
||||
{% else %}bg-blue-50 text-blue-800 border-blue-200{% endif %}">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||
{% with s=stats %}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">DNS Queries</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ s.total }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">Unique Domains</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ s.unique_domains }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">Devices</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ s.unique_clients }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">NXDOMAIN</div>
|
||||
<div class="text-2xl font-bold {% if s.nxdomain_count %}text-red-600{% else %}text-gray-900{% endif %}">
|
||||
{{ s.nxdomain_count }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">NX Rate</div>
|
||||
<div class="text-2xl font-bold {% if s.nxdomain_pct > 10 %}text-orange-500{% else %}text-gray-900{% endif %}">
|
||||
{{ s.nxdomain_pct }}%
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<!-- Charts row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<!-- Query volume timeline -->
|
||||
<div class="lg:col-span-2 bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||
<div class="text-sm font-semibold text-gray-700 mb-3" id="chart-title">{{ range_label }}</div>
|
||||
<div id="timelineChart" style="height:220px;"></div>
|
||||
</div>
|
||||
<!-- Top domains mini chart -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||
<div class="text-sm font-semibold text-gray-700 mb-3">Top Domains — {{ range_label }}</div>
|
||||
<div id="domainsChart" style="height:220px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Geo map -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<div class="text-sm font-semibold text-gray-700">Resolved IP Geography — {{ range_label }}</div>
|
||||
<div id="geo-legend" class="flex items-center gap-3 text-xs text-gray-400 flex-wrap"></div>
|
||||
</div>
|
||||
<div id="geoMap" class="w-full" style="height:320px; position:relative; overflow:hidden;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Top tables -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- Top domains -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-700">
|
||||
Top Queried Domains — {{ range_label }}
|
||||
</div>
|
||||
<table class="w-full text-xs">
|
||||
<thead class="bg-gray-50 text-gray-500">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Domain</th>
|
||||
<th class="px-3 py-2 text-left">Country</th>
|
||||
<th class="px-3 py-2 text-right">Queries</th>
|
||||
<th class="px-3 py-2 text-right">NX</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{% for d in top_domains %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2 font-mono text-gray-800 truncate max-w-[200px]">
|
||||
<button class="text-indigo-600 hover:underline font-mono text-left truncate max-w-full"
|
||||
onclick="setQueryFilter('domain','{{ d.domain|escapejs }}')">{{ d.domain }}</button>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-500">
|
||||
{% if d.country %}
|
||||
{% if d.country_code %}<span class="mr-1">{{ d.country_code }}</span>{% endif %}
|
||||
{{ d.country }}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right font-medium">{{ d.count }}</td>
|
||||
<td class="px-3 py-2 text-right {% if d.nxcount %}text-red-500{% else %}text-gray-400{% endif %}">
|
||||
{{ d.nxcount }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="px-3 py-6 text-center text-gray-400">No data yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Top clients -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-700">
|
||||
Top Clients — {{ range_label }}
|
||||
</div>
|
||||
<table class="w-full text-xs">
|
||||
<thead class="bg-gray-50 text-gray-500">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Client</th>
|
||||
<th class="px-3 py-2 text-left">Hostname</th>
|
||||
<th class="px-3 py-2 text-right">Queries</th>
|
||||
<th class="px-3 py-2 text-right">NX</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{% for c in top_clients %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2 font-mono text-gray-800">
|
||||
<button class="text-indigo-600 hover:underline font-mono"
|
||||
onclick="setQueryFilter('client','{{ c.client_ip|escapejs }}')">{{ c.client_ip }}</button>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-500">{{ c.hostname|default:"—" }}</td>
|
||||
<td class="px-3 py-2 text-right font-medium">{{ c.count }}</td>
|
||||
<td class="px-3 py-2 text-right {% if c.nxcount %}text-red-500{% else %}text-gray-400{% endif %}">
|
||||
{{ c.nxcount }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="px-3 py-6 text-center text-gray-400">No data yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live DNS query stream -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between flex-wrap gap-2">
|
||||
<div class="text-sm font-semibold text-gray-700">Live DNS Queries</div>
|
||||
<!-- Filter controls -->
|
||||
<div class="flex items-center gap-2 flex-wrap text-xs" id="query-filters">
|
||||
<input type="text" id="filter-client" placeholder="Client IP / hostname" class="border border-gray-200 rounded px-2 py-1 text-xs w-36 focus:outline-none focus:ring-1 focus:ring-indigo-300"
|
||||
oninput="reloadQueries()">
|
||||
<input type="text" id="filter-domain" placeholder="Domain" class="border border-gray-200 rounded px-2 py-1 text-xs w-40 focus:outline-none focus:ring-1 focus:ring-indigo-300"
|
||||
oninput="reloadQueries()">
|
||||
<select id="filter-type" class="border border-gray-200 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-300"
|
||||
onchange="reloadQueries()">
|
||||
<option value="">All types</option>
|
||||
<option value="A">A</option>
|
||||
<option value="AAAA">AAAA</option>
|
||||
<option value="PTR">PTR</option>
|
||||
<option value="TXT">TXT</option>
|
||||
<option value="MX">MX</option>
|
||||
<option value="SRV">SRV</option>
|
||||
<option value="HTTPS">HTTPS</option>
|
||||
</select>
|
||||
<label class="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" id="filter-nxdomain" onchange="reloadQueries()">
|
||||
<span class="text-red-600 font-medium">NXDOMAIN only</span>
|
||||
</label>
|
||||
<button onclick="clearFilters()" class="text-gray-400 hover:text-gray-600 text-xs">✕ Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="queries-container"
|
||||
hx-get="{% url 'routermon-queries-partial' %}"
|
||||
hx-trigger="load, every 10s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="px-4 py-6 text-center text-gray-400 text-sm">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── SSE status stream ─────────────────────────────────────────────────────────
|
||||
const statusEs = new EventSource("{% url 'routermon-status' %}");
|
||||
statusEs.onmessage = (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
document.getElementById('status-total').textContent = d.total_queries.toLocaleString() + ' total queries';
|
||||
if (d.last_received_at) {
|
||||
const dt = new Date(d.last_received_at);
|
||||
const ago = Math.round((Date.now() - dt.getTime()) / 1000);
|
||||
document.getElementById('status-last').textContent = 'Last: ' + (ago < 60 ? ago + 's' : Math.round(ago/60) + 'm') + ' ago';
|
||||
}
|
||||
};
|
||||
|
||||
// ── Toggle receiver ───────────────────────────────────────────────────────────
|
||||
async function toggleReceiver() {
|
||||
const btn = document.getElementById('toggle-btn');
|
||||
btn.disabled = true;
|
||||
const res = await fetch("{% url 'routermon-toggle' %}", {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': '{{ csrf_token }}'},
|
||||
});
|
||||
const data = await res.json();
|
||||
const dot = document.getElementById('toggle-dot');
|
||||
const label = document.getElementById('toggle-label');
|
||||
if (data.enabled) {
|
||||
btn.className = btn.className.replace(/bg-gray-\w+ text-gray-\w+ hover:bg-gray-\w+/, 'bg-green-100 text-green-700 hover:bg-green-200');
|
||||
dot.className = dot.className.replace('bg-gray-400', 'bg-green-500');
|
||||
label.textContent = 'Receiving';
|
||||
} else {
|
||||
btn.className = btn.className.replace(/bg-green-\w+ text-green-\w+ hover:bg-green-\w+/, 'bg-gray-200 text-gray-500 hover:bg-gray-300');
|
||||
dot.className = dot.className.replace('bg-green-500', 'bg-gray-400');
|
||||
label.textContent = 'Paused';
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
// ── Live queries HTMX helpers ─────────────────────────────────────────────────
|
||||
function setQueryFilter(field, value) {
|
||||
if (field === 'client') document.getElementById('filter-client').value = value;
|
||||
if (field === 'domain') document.getElementById('filter-domain').value = value;
|
||||
reloadQueries();
|
||||
document.getElementById('queries-container').scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
document.getElementById('filter-client').value = '';
|
||||
document.getElementById('filter-domain').value = '';
|
||||
document.getElementById('filter-type').value = '';
|
||||
document.getElementById('filter-nxdomain').checked = false;
|
||||
reloadQueries();
|
||||
}
|
||||
|
||||
let _reloadTimer = null;
|
||||
function reloadQueries() {
|
||||
clearTimeout(_reloadTimer);
|
||||
_reloadTimer = setTimeout(() => {
|
||||
const client = document.getElementById('filter-client').value.trim();
|
||||
const domain = document.getElementById('filter-domain').value.trim();
|
||||
const type = document.getElementById('filter-type').value;
|
||||
const nx = document.getElementById('filter-nxdomain').checked ? '1' : '';
|
||||
const params = new URLSearchParams();
|
||||
if (client) params.set('client', client);
|
||||
if (domain) params.set('domain', domain);
|
||||
if (type) params.set('type', type);
|
||||
if (nx) params.set('nxdomain', nx);
|
||||
const url = "{% url 'routermon-queries-partial' %}" + (params.toString() ? '?' + params.toString() : '');
|
||||
htmx.ajax('GET', url, '#queries-container');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ── Timeline chart ────────────────────────────────────────────────────────────
|
||||
const timelineChart = echarts.init(document.getElementById('timelineChart'));
|
||||
const domainsChartEl = echarts.init(document.getElementById('domainsChart'));
|
||||
|
||||
async function loadChart() {
|
||||
const range = '{{ current_range }}';
|
||||
const res = await fetch("{% url 'routermon-chart' %}?range=" + range);
|
||||
const d = await res.json();
|
||||
|
||||
timelineChart.setOption({
|
||||
tooltip: {trigger: 'axis'},
|
||||
legend: {data: ['Queries', 'NXDOMAIN'], textStyle: {fontSize: 11}},
|
||||
grid: {left: '3%', right: '3%', bottom: '3%', containLabel: true},
|
||||
xAxis: {type: 'category', data: d.labels, axisLabel: {fontSize: 10}},
|
||||
yAxis: {type: 'value', axisLabel: {fontSize: 10}},
|
||||
series: [
|
||||
{name: 'Queries', type: 'bar', data: d.total, itemStyle: {color: '#6366f1'}, barMaxWidth: 20},
|
||||
{name: 'NXDOMAIN', type: 'bar', data: d.nxcount, itemStyle: {color: '#ef4444'}, barMaxWidth: 20},
|
||||
],
|
||||
});
|
||||
|
||||
// Top domains mini bar chart
|
||||
const topData = {{ top_domains_json }};
|
||||
const top10 = topData.slice(0, 10).reverse();
|
||||
domainsChartEl.setOption({
|
||||
tooltip: {trigger: 'axis', axisPointer: {type: 'shadow'}},
|
||||
grid: {left: '3%', right: '8%', bottom: '3%', containLabel: true},
|
||||
xAxis: {type: 'value', axisLabel: {fontSize: 9}},
|
||||
yAxis: {type: 'category', data: top10.map(d => d.domain.length > 22 ? d.domain.slice(0, 22) + '…' : d.domain),
|
||||
axisLabel: {fontSize: 9}},
|
||||
series: [{
|
||||
type: 'bar', data: top10.map(d => d.count),
|
||||
itemStyle: {color: '#818cf8'},
|
||||
barMaxWidth: 16,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
loadChart();
|
||||
window.addEventListener('resize', () => { timelineChart.resize(); domainsChartEl.resize(); });
|
||||
|
||||
// ── Geo map ───────────────────────────────────────────────────────────────────
|
||||
async function loadGeoMap() {
|
||||
const range = '{{ current_range }}';
|
||||
const [geoRes, worldRes] = await Promise.all([
|
||||
fetch("{% url 'routermon-geo' %}?range=" + range),
|
||||
fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json'),
|
||||
]);
|
||||
const geo = await geoRes.json();
|
||||
const world = await worldRes.json();
|
||||
|
||||
const container = document.getElementById('geoMap');
|
||||
const w = container.clientWidth, h = container.clientHeight;
|
||||
const svg = d3.select('#geoMap').append('svg').attr('width', w).attr('height', h);
|
||||
const proj = d3.geoNaturalEarth1().scale(w / 6.3).translate([w / 2, h / 2]);
|
||||
const path = d3.geoPath(proj);
|
||||
const countries = topojson.feature(world, world.objects.countries);
|
||||
|
||||
// Country colour scale
|
||||
const countMap = Object.fromEntries((geo.countries || []).map(c => [c.country_code, c.total]));
|
||||
const maxCount = Math.max(...Object.values(countMap), 1);
|
||||
const colour = d3.scaleSequential(d3.interpolateBlues).domain([0, maxCount]);
|
||||
|
||||
svg.append('g').selectAll('path')
|
||||
.data(countries.features)
|
||||
.join('path')
|
||||
.attr('d', path)
|
||||
.attr('fill', f => {
|
||||
const iso = String(f.id).padStart(3, '0');
|
||||
// world-atlas uses ISO 3166-1 numeric; we have alpha-2 — best effort match
|
||||
return '#e5e7eb';
|
||||
})
|
||||
.attr('stroke', '#d1d5db').attr('stroke-width', 0.4);
|
||||
|
||||
// Country highlights using geo data
|
||||
(geo.countries || []).forEach(c => {
|
||||
// We'll overlay circles on the bubbles instead of colour fills (alpha-2 ↔ numeric mapping is complex)
|
||||
});
|
||||
|
||||
// Bubble circles for resolved IPs
|
||||
const maxBubble = Math.max(...(geo.bubbles || []).map(b => b.total), 1);
|
||||
const rScale = d3.scaleSqrt().domain([1, maxBubble]).range([2, 22]);
|
||||
|
||||
svg.append('g').selectAll('circle')
|
||||
.data(geo.bubbles || [])
|
||||
.join('circle')
|
||||
.attr('cx', b => proj([b.lon, b.lat])[0])
|
||||
.attr('cy', b => proj([b.lon, b.lat])[1])
|
||||
.attr('r', b => rScale(b.total))
|
||||
.attr('fill', '#6366f1')
|
||||
.attr('fill-opacity', 0.55)
|
||||
.attr('stroke', '#4f46e5')
|
||||
.attr('stroke-width', 0.8)
|
||||
.append('title')
|
||||
.text(b => `${b.label}: ${b.total} queries`);
|
||||
|
||||
// Legend
|
||||
const legend = document.getElementById('geo-legend');
|
||||
(geo.countries || []).slice(0, 5).forEach(c => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'flex items-center gap-1';
|
||||
span.innerHTML = `<span class="w-2 h-2 rounded-full bg-indigo-400 inline-block"></span>${c.country} (${c.total})`;
|
||||
legend.appendChild(span);
|
||||
});
|
||||
}
|
||||
|
||||
loadGeoMap();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,183 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<i class="fas fa-cog text-gray-500"></i> Router Monitor Settings
|
||||
</h1>
|
||||
<p class="text-sm text-gray-400 mt-0.5">UDP syslog receiver configuration</p>
|
||||
</div>
|
||||
<a href="{% url 'routermon-dashboard' %}"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-gray-100 text-gray-600 text-sm rounded-md hover:bg-gray-200">
|
||||
<i class="fas fa-arrow-left mr-1.5"></i> Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% for msg in messages %}
|
||||
<div class="px-4 py-2 rounded-md text-sm border
|
||||
{% if msg.tags == 'success' %}bg-green-50 text-green-800 border-green-200
|
||||
{% elif msg.tags == 'error' %}bg-red-50 text-red-800 border-red-200
|
||||
{% else %}bg-blue-50 text-blue-800 border-blue-200{% endif %}">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Receiver settings -->
|
||||
<div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<h2 class="text-sm font-semibold text-gray-700">Receiver Configuration</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
The server listens for UDP syslog datagrams from your ASUS router's dnsmasq daemon.
|
||||
</p>
|
||||
</div>
|
||||
<form method="post" class="p-6 space-y-4">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}<p class="text-xs text-gray-400 mt-1">{{ field.help_text }}</p>{% endif %}
|
||||
{% for error in field.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="pt-1">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
|
||||
<i class="fas fa-save mr-2"></i> Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Router setup guide -->
|
||||
<div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 bg-blue-50 border-b border-blue-200 flex items-start gap-3">
|
||||
<i class="fas fa-info-circle text-blue-500 mt-0.5"></i>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-blue-800">Router Setup — AsusWRT-Merlin 3006.x (SSH method)</h2>
|
||||
<p class="text-xs text-blue-700 mt-0.5">
|
||||
SSH into your ASUS GT-AX6000 and run the commands below. This works on all Merlin 3006.x builds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 space-y-5 text-sm text-gray-700">
|
||||
|
||||
<!-- Prerequisites -->
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-md p-4 text-xs text-amber-800">
|
||||
<strong>Prerequisite:</strong> Enable SSH in the Merlin UI first —
|
||||
<strong>Administration → System → SSH Daemon</strong>, set to LAN only, then save.
|
||||
Also enable <strong>JFFS custom scripts and configs</strong> on the same page if not already enabled.
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="font-semibold text-gray-800 flex items-center gap-2">
|
||||
<span class="w-6 h-6 rounded-full bg-indigo-100 text-indigo-700 text-xs font-bold flex items-center justify-center">1</span>
|
||||
Enable DNS query logging in dnsmasq
|
||||
</h3>
|
||||
<p class="text-gray-600 text-xs ml-8">
|
||||
SSH into the router and add a dnsmasq option that persists across reboots via JFFS:
|
||||
</p>
|
||||
<pre class="bg-gray-900 text-green-300 text-xs rounded p-3 overflow-x-auto ml-8"><code>ssh admin@192.168.1.1
|
||||
|
||||
# Add log-queries to dnsmasq (persistent via JFFS)
|
||||
echo "log-queries" >> /jffs/configs/dnsmasq.conf.add
|
||||
|
||||
# Apply immediately (no reboot needed)
|
||||
service restart_dnsmasq</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="font-semibold text-gray-800 flex items-center gap-2">
|
||||
<span class="w-6 h-6 rounded-full bg-indigo-100 text-indigo-700 text-xs font-bold flex items-center justify-center">2</span>
|
||||
Configure remote syslog forwarding
|
||||
</h3>
|
||||
<p class="text-gray-600 text-xs ml-8">
|
||||
Set nvram variables to forward syslog to this server, then restart the syslog daemon:
|
||||
</p>
|
||||
<pre class="bg-gray-900 text-green-300 text-xs rounded p-3 overflow-x-auto ml-8"><code># Still in the SSH session:
|
||||
nvram set log_remote=1
|
||||
nvram set log_ipaddr=192.168.1.2
|
||||
nvram set log_port={{ settings.syslog_port }}
|
||||
nvram commit
|
||||
|
||||
service restart_syslog</code></pre>
|
||||
<p class="text-gray-500 text-xs ml-8">
|
||||
DNS queries should appear on the dashboard within a few seconds of the next DNS lookup on your network.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="font-semibold text-gray-800 flex items-center gap-2">
|
||||
<span class="w-6 h-6 rounded-full bg-indigo-100 text-indigo-700 text-xs font-bold flex items-center justify-center">3</span>
|
||||
Verify syslog is arriving
|
||||
</h3>
|
||||
<pre class="bg-gray-900 text-green-300 text-xs rounded p-3 overflow-x-auto ml-8"><code># On the k3s node (192.168.1.2) — listen for UDP packets:
|
||||
nc -ulk {{ settings.syslog_port }}
|
||||
|
||||
# Or watch with tcpdump:
|
||||
tcpdump -i any -A udp port {{ settings.syslog_port }}
|
||||
|
||||
# You should see lines like:
|
||||
# dnsmasq[1234]: query[A] google.com from 192.168.1.x
|
||||
# dnsmasq[1234]: reply google.com is 142.250.80.46</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- Port note -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<h4 class="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
|
||||
<i class="fas fa-info-circle text-gray-400"></i>
|
||||
If port {{ settings.syslog_port }} is not reachable from the router
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 mb-2">
|
||||
If the router can only send to the standard syslog port (514), redirect it on the k3s node:
|
||||
</p>
|
||||
<pre class="bg-gray-900 text-green-300 text-xs rounded p-3 overflow-x-auto"><code># On the k3s node, run as root — then change log_port to 514 in nvram above
|
||||
iptables -t nat -A PREROUTING -p udp --dport 514 -j REDIRECT --to-port {{ settings.syslog_port }}
|
||||
|
||||
# Persist across reboots (Debian/Ubuntu):
|
||||
apt install iptables-persistent && netfilter-persistent save</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- Revert -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<h4 class="text-xs font-semibold text-gray-700 mb-2">To disable / revert</h4>
|
||||
<pre class="bg-gray-900 text-green-300 text-xs rounded p-3 overflow-x-auto"><code>ssh admin@192.168.1.1
|
||||
|
||||
# Remove dnsmasq log-queries line
|
||||
sed -i '/^log-queries$/d' /jffs/configs/dnsmasq.conf.add
|
||||
|
||||
# Disable remote syslog
|
||||
nvram set log_remote=0
|
||||
nvram commit
|
||||
|
||||
service restart_dnsmasq
|
||||
service restart_syslog</code></pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current status -->
|
||||
<div class="bg-white shadow-sm rounded-lg border border-gray-200 p-5">
|
||||
<h2 class="text-sm font-semibold text-gray-700 mb-3">Receiver Status</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<dt class="text-gray-500">Status</dt>
|
||||
<dd class="font-medium {% if settings.enabled %}text-green-600{% else %}text-gray-400{% endif %}">
|
||||
{% if settings.enabled %}Enabled (listening on UDP :{{ settings.syslog_port }}){% else %}Disabled{% endif %}
|
||||
</dd>
|
||||
<dt class="text-gray-500">Last received</dt>
|
||||
<dd class="font-medium text-gray-800">
|
||||
{% if settings.last_received_at %}{{ settings.last_received_at|date:"Y-m-d H:i:s" }}{% else %}Never{% endif %}
|
||||
</dd>
|
||||
<dt class="text-gray-500">Retention</dt>
|
||||
<dd class="font-medium text-gray-800">{{ settings.retention_days }} days</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.DashboardView.as_view(), name='routermon-dashboard'),
|
||||
path('settings/', views.SettingsView.as_view(), name='routermon-settings'),
|
||||
# HTMX partials
|
||||
path('partials/queries/', views.LiveQueriesPartialView.as_view(), name='routermon-queries-partial'),
|
||||
# JSON APIs
|
||||
path('api/chart/', views.ChartDataView.as_view(), name='routermon-chart'),
|
||||
path('api/geo/', views.GeoStatsView.as_view(), name='routermon-geo'),
|
||||
# Actions
|
||||
path('toggle/', views.ToggleView.as_view(), name='routermon-toggle'),
|
||||
path('status/', views.StatusStreamView.as_view(), name='routermon-status'),
|
||||
]
|
||||
@@ -0,0 +1,307 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models.functions import TruncDay, TruncHour, TruncMinute
|
||||
from django.http import JsonResponse, StreamingHttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils import timezone
|
||||
from django.views.generic import TemplateView, View
|
||||
|
||||
from .forms import RouterMonSettingsForm
|
||||
from .models import DhcpLease, DnsQuery, RouterMonSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Time range config (mirrors nginxmon) ─────────────────────────────────────
|
||||
|
||||
_RANGES = {
|
||||
'5m': {'delta': timedelta(minutes=5), 'trunc': TruncMinute, 'group_n': 1, 'label': 'Last 5 min', 'fmt': '%H:%M'},
|
||||
'30m': {'delta': timedelta(minutes=30), 'trunc': TruncMinute, 'group_n': 2, 'label': 'Last 30 min', 'fmt': '%H:%M'},
|
||||
'6h': {'delta': timedelta(hours=6), 'trunc': TruncMinute, 'group_n': 15, 'label': 'Last 6 hours', 'fmt': '%H:%M'},
|
||||
'1d': {'delta': timedelta(hours=24), 'trunc': TruncHour, 'group_n': 1, 'label': 'Last 24 hours', 'fmt': '%H:%M'},
|
||||
'7d': {'delta': timedelta(days=7), 'trunc': TruncHour, 'group_n': 4, 'label': 'Last 7 days', 'fmt': '%m/%d %H:%M'},
|
||||
'30d': {'delta': timedelta(days=30), 'trunc': TruncDay, 'group_n': 1, 'label': 'Last 30 days', 'fmt': '%m/%d'},
|
||||
}
|
||||
_DEFAULT_RANGE = '30m'
|
||||
|
||||
|
||||
def _get_range(request):
|
||||
key = request.GET.get('range', _DEFAULT_RANGE)
|
||||
if key not in _RANGES:
|
||||
key = _DEFAULT_RANGE
|
||||
cfg = _RANGES[key]
|
||||
return key, cfg, timezone.now() - cfg['delta']
|
||||
|
||||
|
||||
def _floor_bucket_key(dt, trunc_fn, group_n, fmt):
|
||||
if trunc_fn is TruncMinute:
|
||||
m = (dt.minute // group_n) * group_n
|
||||
aligned = dt.replace(minute=m, second=0, microsecond=0)
|
||||
elif trunc_fn is TruncHour:
|
||||
h = (dt.hour // group_n) * group_n
|
||||
aligned = dt.replace(hour=h, minute=0, second=0, microsecond=0)
|
||||
else:
|
||||
from datetime import date
|
||||
epoch = date(2020, 1, 1)
|
||||
d = dt.date() if hasattr(dt, 'date') else dt
|
||||
days = (d - epoch).days
|
||||
floored = epoch + timedelta(days=(days // group_n) * group_n)
|
||||
aligned = dt.replace(
|
||||
year=floored.year, month=floored.month, day=floored.day,
|
||||
hour=0, minute=0, second=0, microsecond=0,
|
||||
)
|
||||
return aligned.strftime(fmt)
|
||||
|
||||
|
||||
# ── Dashboard ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class DashboardView(TemplateView):
|
||||
template_name = 'routermon/dashboard.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
range_key, cfg, since = _get_range(self.request)
|
||||
settings = RouterMonSettings.get()
|
||||
|
||||
qs = DnsQuery.objects.filter(timestamp__gte=since)
|
||||
total = qs.count()
|
||||
nxdomain_count = qs.filter(is_nxdomain=True).count()
|
||||
|
||||
stats = {
|
||||
'total': total,
|
||||
'unique_domains': qs.values('domain').distinct().count(),
|
||||
'unique_clients': qs.values('client_ip').distinct().count(),
|
||||
'nxdomain_count': nxdomain_count,
|
||||
'nxdomain_pct': round(nxdomain_count / total * 100, 1) if total else 0,
|
||||
}
|
||||
|
||||
top_domains = list(
|
||||
qs.values('domain')
|
||||
.annotate(
|
||||
count=Count('id'),
|
||||
nxcount=Count('id', filter=Q(is_nxdomain=True)),
|
||||
)
|
||||
.order_by('-count')[:20]
|
||||
)
|
||||
|
||||
# Attach geo (country) to top domains via resolved_ip
|
||||
domain_geo = {}
|
||||
for row in qs.filter(resolved_ip__isnull=False, country__gt='').values('domain', 'country_code', 'country').distinct()[:100]:
|
||||
if row['domain'] not in domain_geo:
|
||||
domain_geo[row['domain']] = {'country': row['country'], 'country_code': row['country_code']}
|
||||
for d in top_domains:
|
||||
geo = domain_geo.get(d['domain'], {})
|
||||
d['country'] = geo.get('country', '')
|
||||
d['country_code'] = geo.get('country_code', '')
|
||||
|
||||
# Build client hostname map from DhcpLease
|
||||
client_ips = list(qs.values_list('client_ip', flat=True).distinct()[:50])
|
||||
hostname_map = {
|
||||
lease.ip: lease.hostname
|
||||
for lease in DhcpLease.objects.filter(ip__in=client_ips)
|
||||
if lease.hostname
|
||||
}
|
||||
|
||||
top_clients = list(
|
||||
qs.values('client_ip', 'client_name')
|
||||
.annotate(count=Count('id'), nxcount=Count('id', filter=Q(is_nxdomain=True)))
|
||||
.order_by('-count')[:15]
|
||||
)
|
||||
for c in top_clients:
|
||||
c['hostname'] = hostname_map.get(c['client_ip'], c['client_name'] or '')
|
||||
|
||||
ctx.update({
|
||||
'settings': settings,
|
||||
'stats': stats,
|
||||
'top_domains': top_domains,
|
||||
'top_clients': top_clients,
|
||||
'current_range': range_key,
|
||||
'range_label': cfg['label'],
|
||||
'all_ranges': [(k, v['label']) for k, v in _RANGES.items()],
|
||||
'top_domains_json': json.dumps(top_domains, default=str),
|
||||
})
|
||||
return ctx
|
||||
|
||||
|
||||
# ── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class SettingsView(View):
|
||||
template_name = 'routermon/settings.html'
|
||||
|
||||
def get(self, request):
|
||||
obj = RouterMonSettings.get()
|
||||
return render(request, self.template_name, {
|
||||
'form': RouterMonSettingsForm(instance=obj),
|
||||
'settings': obj,
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
obj = RouterMonSettings.get()
|
||||
form = RouterMonSettingsForm(request.POST, instance=obj)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, 'Router Monitor settings saved.')
|
||||
return redirect('routermon-settings')
|
||||
return render(request, self.template_name, {'form': form, 'settings': obj})
|
||||
|
||||
|
||||
# ── HTMX partials ─────────────────────────────────────────────────────────────
|
||||
|
||||
class LiveQueriesPartialView(View):
|
||||
_SORT_MAP = {
|
||||
'timestamp': 'timestamp',
|
||||
'client': 'client_ip',
|
||||
'domain': 'domain',
|
||||
'type': 'query_type',
|
||||
}
|
||||
|
||||
def get(self, request):
|
||||
qs = DnsQuery.objects.all()
|
||||
|
||||
client_f = request.GET.get('client', '').strip()
|
||||
domain_f = request.GET.get('domain', '').strip()
|
||||
type_f = request.GET.get('type', '').strip()
|
||||
nx_f = request.GET.get('nxdomain', '').strip()
|
||||
sort_col = request.GET.get('sort', 'timestamp')
|
||||
sort_ord = request.GET.get('order', 'desc')
|
||||
|
||||
if client_f:
|
||||
qs = qs.filter(Q(client_ip=client_f) | Q(client_name__icontains=client_f))
|
||||
if domain_f:
|
||||
qs = qs.filter(domain__icontains=domain_f)
|
||||
if type_f:
|
||||
qs = qs.filter(query_type__iexact=type_f)
|
||||
if nx_f == '1':
|
||||
qs = qs.filter(is_nxdomain=True)
|
||||
|
||||
db_col = self._SORT_MAP.get(sort_col, 'timestamp')
|
||||
qs = qs.order_by(f'{"" if sort_ord == "asc" else "-"}{db_col}')
|
||||
|
||||
return render(request, 'routermon/_live_queries.html', {'queries': qs[:60]})
|
||||
|
||||
|
||||
class GeoStatsView(View):
|
||||
"""Country-level aggregates + bubble data for the geo map."""
|
||||
|
||||
def get(self, request):
|
||||
range_key, cfg, since = _get_range(request)
|
||||
qs = DnsQuery.objects.filter(timestamp__gte=since)
|
||||
|
||||
countries = list(
|
||||
qs.exclude(country_code='')
|
||||
.values('country_code', 'country')
|
||||
.annotate(total=Count('id'), nxcount=Count('id', filter=Q(is_nxdomain=True)))
|
||||
.order_by('-total')
|
||||
)
|
||||
|
||||
# Bubble data from IPGeoCache for resolved IPs
|
||||
ip_agg = list(
|
||||
qs.filter(resolved_ip__isnull=False)
|
||||
.values('resolved_ip')
|
||||
.annotate(total=Count('id'))
|
||||
.order_by('-total')[:300]
|
||||
)
|
||||
ip_set = [r['resolved_ip'] for r in ip_agg]
|
||||
|
||||
try:
|
||||
from nginxmon.models import IPGeoCache
|
||||
geo_cache = {
|
||||
c.ip: (c.lat, c.lon, c.country, c.city)
|
||||
for c in IPGeoCache.objects.filter(ip__in=ip_set, lat__isnull=False, is_private=False)
|
||||
}
|
||||
except Exception:
|
||||
geo_cache = {}
|
||||
|
||||
bubbles = []
|
||||
for row in ip_agg:
|
||||
c = geo_cache.get(row['resolved_ip'])
|
||||
if c and c[0] is not None and c[1] is not None:
|
||||
bubbles.append({
|
||||
'lat': round(float(c[0]), 2),
|
||||
'lon': round(float(c[1]), 2),
|
||||
'total': row['total'],
|
||||
'label': c[3] or c[2] or row['resolved_ip'],
|
||||
})
|
||||
|
||||
return JsonResponse({'countries': countries, 'bubbles': bubbles, 'range': range_key})
|
||||
|
||||
|
||||
# ── Chart data ────────────────────────────────────────────────────────────────
|
||||
|
||||
class ChartDataView(View):
|
||||
def get(self, request):
|
||||
range_key, cfg, since = _get_range(request)
|
||||
trunc_fn = cfg['trunc']
|
||||
group_n = cfg['group_n']
|
||||
fmt = cfg['fmt']
|
||||
|
||||
rows = list(
|
||||
DnsQuery.objects
|
||||
.filter(timestamp__gte=since)
|
||||
.annotate(bucket=trunc_fn('timestamp'))
|
||||
.values('bucket')
|
||||
.annotate(
|
||||
total=Count('id'),
|
||||
nxcount=Count('id', filter=Q(is_nxdomain=True)),
|
||||
)
|
||||
.order_by('bucket')
|
||||
)
|
||||
|
||||
buckets: dict = {}
|
||||
for row in rows:
|
||||
bkt = row['bucket']
|
||||
if bkt is None:
|
||||
continue
|
||||
key = _floor_bucket_key(bkt, trunc_fn, group_n, fmt)
|
||||
if key not in buckets:
|
||||
buckets[key] = {'total': 0, 'nxcount': 0}
|
||||
buckets[key]['total'] += row['total']
|
||||
buckets[key]['nxcount'] += row['nxcount']
|
||||
|
||||
return JsonResponse({
|
||||
'labels': list(buckets),
|
||||
'total': [v['total'] for v in buckets.values()],
|
||||
'nxcount': [v['nxcount'] for v in buckets.values()],
|
||||
'range': range_key,
|
||||
'label': cfg['label'],
|
||||
})
|
||||
|
||||
|
||||
# ── Toggle + SSE status stream ─────────────────────────────────────────────────
|
||||
|
||||
class ToggleView(View):
|
||||
def post(self, request):
|
||||
settings = RouterMonSettings.get()
|
||||
settings.enabled = not settings.enabled
|
||||
settings.save(update_fields=['enabled'])
|
||||
return JsonResponse({'enabled': settings.enabled})
|
||||
|
||||
|
||||
class StatusStreamView(View):
|
||||
"""SSE — pushes receiver status JSON every 3 seconds."""
|
||||
|
||||
def get(self, request):
|
||||
def event_stream():
|
||||
while True:
|
||||
try:
|
||||
settings = RouterMonSettings.get()
|
||||
total = DnsQuery.objects.count()
|
||||
last = settings.last_received_at.isoformat() if settings.last_received_at else None
|
||||
payload = json.dumps({
|
||||
'enabled': settings.enabled,
|
||||
'last_received_at': last,
|
||||
'total_queries': total,
|
||||
})
|
||||
yield f'data: {payload}\n\n'
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(3)
|
||||
|
||||
resp = StreamingHttpResponse(event_stream(), content_type='text/event-stream')
|
||||
resp['Cache-Control'] = 'no-cache'
|
||||
resp['X-Accel-Buffering'] = 'no'
|
||||
return resp
|
||||
@@ -151,6 +151,16 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'routermon-dashboard' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.142 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"/>
|
||||
</svg>
|
||||
{% trans "Router Monitor" %}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'file-list' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
Reference in New Issue
Block a user