Files
2026-04-01 21:52:29 +11:00

225 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django.db import models
from netscan.fields import EncryptedCharField
class NginxSettings(models.Model):
"""
Singleton (pk=1) — the one-off cluster connection config.
Use NginxSettings.get() everywhere instead of pk lookups.
"""
namespace = models.CharField(max_length=200, default='ingress-nginx')
pod_label = models.CharField(
max_length=200,
default='app.kubernetes.io/name=ingress-nginx',
help_text='kubectl -l selector for the ingress-nginx pods',
)
container = models.CharField(max_length=100, default='controller')
fetch_interval_seconds = models.IntegerField(
default=30,
help_text='How often to pull new logs (seconds)',
)
# Local dev / testing: read from a file instead of kubectl
log_file_path = models.CharField(
max_length=500,
blank=True,
help_text=(
'Absolute path to a local nginx log file for testing. '
'Leave empty to use kubectl in production.'
),
)
enabled = models.BooleanField(default=True)
last_fetch_at = models.DateTimeField(null=True, blank=True)
excluded_ips = models.TextField(
blank=True,
default='',
help_text=(
'One entry per line. Supports exact IPs (192.168.1.218), '
'wildcards (192.168.1.x), and CIDR ranges (192.168.1.0/24). '
'Matching requests will not be ingested.'
),
)
auto_ban_enabled = models.BooleanField(
default=True,
help_text='Automatically ban IPs that repeatedly probe auth endpoints.',
)
auto_ban_threshold = models.IntegerField(
default=5,
help_text='Number of auth-probe requests from one IP within the window before auto-ban.',
)
auto_ban_window_hours = models.IntegerField(
default=24,
help_text='Rolling window (hours) used to count auth-probe requests.',
)
class Meta:
verbose_name = 'Nginx Settings'
verbose_name_plural = 'Nginx Settings'
def __str__(self):
return f'Nginx Settings ({self.namespace})'
@classmethod
def get(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
@property
def source_display(self):
if self.log_file_path:
return f'File: {self.log_file_path}'
return f'kubectl -n {self.namespace} -l {self.pod_label}'
class NginxAlertProfile(models.Model):
"""
One or more alert profiles — each with its own thresholds and Telegram config.
"""
name = models.CharField(max_length=200)
alert_window_seconds = models.IntegerField(
default=60,
help_text='Rolling window in seconds for rate analysis',
)
alert_max_requests = models.IntegerField(
default=200,
help_text='Max requests per IP per window before triggering a rate alert',
)
alert_max_error_rate = models.FloatField(
default=0.6,
help_text='Error rate threshold (01) to flag an IP',
)
alert_min_requests = models.IntegerField(
default=15,
help_text='Minimum requests before running error-rate check',
)
telegram_bot_token = EncryptedCharField(blank=True)
telegram_chat_id = EncryptedCharField(blank=True)
enabled = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class NginxAccessLog(models.Model):
"""Stores parsed nginx access log entries (cluster-global, no profile FK)."""
timestamp = models.DateTimeField(db_index=True)
remote_addr = models.GenericIPAddressField(db_index=True)
method = models.CharField(max_length=20)
request_uri = models.TextField()
protocol = models.CharField(max_length=20)
status = models.IntegerField(db_index=True)
body_bytes_sent = models.IntegerField()
http_referer = models.TextField(blank=True)
http_user_agent = models.TextField(blank=True)
request_length = models.IntegerField(default=0)
request_time = models.FloatField()
service = models.CharField(max_length=200, blank=True, db_index=True)
upstream_addr = models.CharField(max_length=200, blank=True)
upstream_response_time = models.FloatField(null=True, blank=True)
upstream_status = models.IntegerField(null=True, blank=True)
request_id = models.CharField(max_length=100, blank=True, db_index=True)
# Auto-ban annotation
note = models.TextField(blank=True, default='')
# Geo (populated after insert)
country = models.CharField(max_length=100, blank=True)
country_code = models.CharField(max_length=10, blank=True)
region = models.CharField(max_length=100, blank=True)
city = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['-timestamp']
indexes = [
models.Index(fields=['remote_addr', 'timestamp']),
models.Index(fields=['timestamp', 'status']), # covers chart aggregation queries
]
@property
def geo_display(self):
parts = [p for p in [self.city, self.country] if p]
return ', '.join(parts) if parts else '—'
class IPGeoCache(models.Model):
ip = models.GenericIPAddressField(unique=True, db_index=True)
country = models.CharField(max_length=100, blank=True)
country_code = models.CharField(max_length=10, blank=True)
region = models.CharField(max_length=100, blank=True)
city = models.CharField(max_length=100, blank=True)
lat = models.FloatField(null=True, blank=True)
lon = models.FloatField(null=True, blank=True)
isp = models.CharField(max_length=300, blank=True)
is_private = models.BooleanField(default=False)
fetched_at = models.DateTimeField(auto_now=True)
def __str__(self):
if self.is_private:
return f'{self.ip} (private)'
parts = [p for p in [self.city, self.country] if p]
return f'{self.ip}{", ".join(parts)}'
class ThreatAlert(models.Model):
ALERT_TYPES = [
('rate_limit', 'High Request Rate'),
('error_rate', 'High Error Rate'),
]
profile = models.ForeignKey(
NginxAlertProfile, on_delete=models.CASCADE, related_name='alerts'
)
alert_type = models.CharField(max_length=50, choices=ALERT_TYPES)
remote_addr = models.GenericIPAddressField(db_index=True)
detected_at = models.DateTimeField(auto_now_add=True)
window_start = models.DateTimeField()
window_end = models.DateTimeField()
request_count = models.IntegerField()
error_count = models.IntegerField(default=0)
detail = models.TextField()
notified = models.BooleanField(default=False)
dismissed = models.BooleanField(default=False)
country = models.CharField(max_length=100, blank=True)
city = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['-detected_at']
def __str__(self):
return f'[{self.get_alert_type_display()}] {self.remote_addr} @ {self.detected_at:%Y-%m-%d %H:%M}'
@property
def error_rate(self):
return self.error_count / self.request_count if self.request_count else 0
class BannedIP(models.Model):
"""
Permanent record of every IP banned by the system (auto or manual).
The IP is also pushed to the Kubernetes nginx ConfigMap block list.
"""
BAN_SOURCES = [
('manual', 'Manual'),
('auto_auth_probe', 'Auto: Auth Probe'),
('auto_php_scan', 'Auto: PHP Scanner'),
('auto_404_flood', 'Auto: 404 Flood'),
]
ip = models.GenericIPAddressField(unique=True, db_index=True)
ban_source = models.CharField(max_length=30, choices=BAN_SOURCES, default='manual')
reason = models.TextField()
request_count = models.IntegerField(default=0)
banned_at = models.DateTimeField(auto_now_add=True)
unbanned_at = models.DateTimeField(null=True, blank=True)
country = models.CharField(max_length=100, blank=True)
city = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['-banned_at']
@property
def is_unbanned(self):
return self.unbanned_at is not None
def __str__(self):
return f'{self.ip} [{self.get_ban_source_display()}] @ {self.banned_at:%Y-%m-%d %H:%M}'