mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Add auto ban feature!
This commit is contained in:
@@ -102,3 +102,5 @@ DerivedData/
|
|||||||
.swiftpm/
|
.swiftpm/
|
||||||
.build/
|
.build/
|
||||||
data/db.sqlite3
|
data/db.sqlite3
|
||||||
|
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
+221
-46
@@ -78,6 +78,10 @@ def fetch_and_store() -> int:
|
|||||||
NginxAccessLog.objects.bulk_create(new_logs, ignore_conflicts=True)
|
NginxAccessLog.objects.bulk_create(new_logs, ignore_conflicts=True)
|
||||||
logger.info('nginxmon: inserted %d new log entries', len(new_logs))
|
logger.info('nginxmon: inserted %d new log entries', len(new_logs))
|
||||||
enrich_geo_batch(new_logs)
|
enrich_geo_batch(new_logs)
|
||||||
|
if settings.auto_ban_enabled:
|
||||||
|
auto_ban_auth_scanners(settings)
|
||||||
|
auto_ban_php_scanners(settings)
|
||||||
|
auto_ban_404_flood(settings)
|
||||||
|
|
||||||
_touch(settings)
|
_touch(settings)
|
||||||
return len(new_logs)
|
return len(new_logs)
|
||||||
@@ -132,10 +136,219 @@ def ingest_raw(text: str) -> int:
|
|||||||
NginxAccessLog.objects.bulk_create(new_logs, ignore_conflicts=True)
|
NginxAccessLog.objects.bulk_create(new_logs, ignore_conflicts=True)
|
||||||
logger.info('nginxmon: ingested %d log entries from raw text', len(new_logs))
|
logger.info('nginxmon: ingested %d log entries from raw text', len(new_logs))
|
||||||
enrich_geo_batch(new_logs)
|
enrich_geo_batch(new_logs)
|
||||||
|
if settings.auto_ban_enabled:
|
||||||
|
auto_ban_auth_scanners(settings)
|
||||||
|
auto_ban_php_scanners(settings)
|
||||||
|
auto_ban_404_flood(settings)
|
||||||
|
|
||||||
return len(new_logs)
|
return len(new_logs)
|
||||||
|
|
||||||
|
|
||||||
|
# Auth-probe paths that signal someone trying to log in / brute-force OAuth
|
||||||
|
_AUTH_PROBE_PREFIXES = ('/oauth2/', '/authorize')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _push_bans(settings: NginxSettings, candidate_ips: list[str],
|
||||||
|
ban_source: str, make_reason, make_note,
|
||||||
|
window_start) -> list[str]:
|
||||||
|
"""
|
||||||
|
Common routine:
|
||||||
|
1. Filter out excluded / private IPs.
|
||||||
|
2. Load ConfigMap, find new offenders, write back.
|
||||||
|
3. Create BannedIP records.
|
||||||
|
4. Annotate matching log rows.
|
||||||
|
Returns list of newly banned IPs.
|
||||||
|
"""
|
||||||
|
from django.db.models import Q
|
||||||
|
from .models import BannedIP
|
||||||
|
|
||||||
|
excluder = _build_excluder(settings.excluded_ips)
|
||||||
|
candidate_ips = [ip for ip in candidate_ips
|
||||||
|
if not excluder(ip) and not _is_private_ip(ip)]
|
||||||
|
if not candidate_ips:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
from links.mini_apps_views import _read_blocked_ips, _write_blocked_ips
|
||||||
|
blocked = _read_blocked_ips()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('nginxmon auto_ban: could not read ConfigMap: %s', exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
already_in_db = set(
|
||||||
|
BannedIP.objects.filter(ip__in=candidate_ips).values_list('ip', flat=True)
|
||||||
|
)
|
||||||
|
newly_banned = [ip for ip in candidate_ips
|
||||||
|
if ip not in blocked and ip not in already_in_db]
|
||||||
|
if not newly_banned:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Push to ConfigMap
|
||||||
|
try:
|
||||||
|
_write_blocked_ips(blocked + newly_banned)
|
||||||
|
logger.info('nginxmon auto_ban [%s]: banned %d IPs: %s',
|
||||||
|
ban_source, len(newly_banned), newly_banned)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('nginxmon auto_ban: could not write ConfigMap: %s', exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Persist BannedIP records and annotate log rows
|
||||||
|
for ip in newly_banned:
|
||||||
|
reason = make_reason(ip, window_start)
|
||||||
|
note = f'Auto banned by system. Reason: {reason}.'
|
||||||
|
|
||||||
|
# Geo from cache
|
||||||
|
from .models import IPGeoCache
|
||||||
|
geo = IPGeoCache.objects.filter(ip=ip).first()
|
||||||
|
BannedIP.objects.get_or_create(
|
||||||
|
ip=ip,
|
||||||
|
defaults=dict(
|
||||||
|
ban_source=ban_source,
|
||||||
|
reason=reason,
|
||||||
|
request_count=NginxAccessLog.objects.filter(
|
||||||
|
remote_addr=ip, timestamp__gte=window_start).count(),
|
||||||
|
country=geo.country if geo else '',
|
||||||
|
city=geo.city if geo else '',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Annotate all matching log rows for this IP in the window
|
||||||
|
NginxAccessLog.objects.filter(
|
||||||
|
remote_addr=ip, timestamp__gte=window_start,
|
||||||
|
).update(note=note)
|
||||||
|
|
||||||
|
return newly_banned
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Detector: repeated auth-probe failures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def auto_ban_auth_scanners(settings: NginxSettings) -> list[str]:
|
||||||
|
"""
|
||||||
|
Ban IPs with > threshold *failed* (4xx/5xx) requests to /oauth2/ or /authorize
|
||||||
|
within the rolling window.
|
||||||
|
"""
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
window_start = dj_tz.now() - timedelta(hours=settings.auto_ban_window_hours)
|
||||||
|
|
||||||
|
probe_filter = Q()
|
||||||
|
for prefix in _AUTH_PROBE_PREFIXES:
|
||||||
|
probe_filter |= Q(request_uri__startswith=prefix)
|
||||||
|
|
||||||
|
candidates = list(
|
||||||
|
NginxAccessLog.objects
|
||||||
|
.filter(probe_filter, timestamp__gte=window_start, status__gte=400)
|
||||||
|
.values('remote_addr')
|
||||||
|
.annotate(cnt=Count('id'))
|
||||||
|
.filter(cnt__gt=settings.auto_ban_threshold)
|
||||||
|
.values_list('remote_addr', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_reason(ip, ws):
|
||||||
|
paths = list(
|
||||||
|
NginxAccessLog.objects.filter(
|
||||||
|
probe_filter, remote_addr=ip, timestamp__gte=ws, status__gte=400,
|
||||||
|
).values_list('request_uri', flat=True)[:20]
|
||||||
|
)
|
||||||
|
hit = sorted({p for prefix in _AUTH_PROBE_PREFIXES for p in paths if p.startswith(prefix)})
|
||||||
|
return 'repeated failed auth-probe requests to: ' + (', '.join(hit) or ', '.join(_AUTH_PROBE_PREFIXES))
|
||||||
|
|
||||||
|
return _push_bans(settings, candidates, 'auto_auth_probe', make_reason, None, window_start)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Detector: PHP webshell / backdoor scanner
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def auto_ban_php_scanners(settings: NginxSettings) -> list[str]:
|
||||||
|
"""
|
||||||
|
Ban IPs probing random .php paths (classic webshell/backdoor scanners).
|
||||||
|
Threshold reuses auto_ban_threshold (default 5). Any IP making > threshold
|
||||||
|
requests to *.php paths (excluding the known auth endpoints) gets banned.
|
||||||
|
"""
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
window_start = dj_tz.now() - timedelta(hours=settings.auto_ban_window_hours)
|
||||||
|
|
||||||
|
# Match paths ending in .php or containing .php? — but NOT auth endpoints
|
||||||
|
auth_filter = Q()
|
||||||
|
for prefix in _AUTH_PROBE_PREFIXES:
|
||||||
|
auth_filter |= Q(request_uri__startswith=prefix)
|
||||||
|
|
||||||
|
php_filter = Q(request_uri__iregex=r'\.php(\?|$|/)')
|
||||||
|
|
||||||
|
candidates = list(
|
||||||
|
NginxAccessLog.objects
|
||||||
|
.filter(php_filter, timestamp__gte=window_start)
|
||||||
|
.exclude(auth_filter)
|
||||||
|
.values('remote_addr')
|
||||||
|
.annotate(cnt=Count('id'))
|
||||||
|
.filter(cnt__gt=settings.auto_ban_threshold)
|
||||||
|
.values_list('remote_addr', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_reason(ip, ws):
|
||||||
|
sample = list(
|
||||||
|
NginxAccessLog.objects.filter(
|
||||||
|
php_filter, remote_addr=ip, timestamp__gte=ws,
|
||||||
|
).values_list('request_uri', flat=True)[:5]
|
||||||
|
)
|
||||||
|
cnt = NginxAccessLog.objects.filter(
|
||||||
|
php_filter, remote_addr=ip, timestamp__gte=ws,
|
||||||
|
).count()
|
||||||
|
return (f'PHP webshell/backdoor scanner — {cnt} probes to .php paths, '
|
||||||
|
f'e.g.: {", ".join(sample[:3])}')
|
||||||
|
|
||||||
|
return _push_bans(settings, candidates, 'auto_php_scan', make_reason, None, window_start)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Detector: 404 flood (scraper / content scanner)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def auto_ban_404_flood(settings: NginxSettings) -> list[str]:
|
||||||
|
"""
|
||||||
|
Ban IPs generating an abnormally high number of 404s (badge scrapers,
|
||||||
|
content scanners, etc.). Threshold = 5 × auto_ban_threshold.
|
||||||
|
"""
|
||||||
|
from django.db.models import Count
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
window_start = dj_tz.now() - timedelta(hours=settings.auto_ban_window_hours)
|
||||||
|
threshold = settings.auto_ban_threshold * 5 # harsher: 25 by default
|
||||||
|
|
||||||
|
candidates = list(
|
||||||
|
NginxAccessLog.objects
|
||||||
|
.filter(status=404, timestamp__gte=window_start)
|
||||||
|
.values('remote_addr')
|
||||||
|
.annotate(cnt=Count('id'))
|
||||||
|
.filter(cnt__gt=threshold)
|
||||||
|
.values_list('remote_addr', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_reason(ip, ws):
|
||||||
|
cnt = NginxAccessLog.objects.filter(
|
||||||
|
remote_addr=ip, status=404, timestamp__gte=ws,
|
||||||
|
).count()
|
||||||
|
sample = list(
|
||||||
|
NginxAccessLog.objects.filter(
|
||||||
|
remote_addr=ip, status=404, timestamp__gte=ws,
|
||||||
|
).values_list('request_uri', flat=True)[:3]
|
||||||
|
)
|
||||||
|
return (f'404 flood — {cnt} consecutive 404 responses, '
|
||||||
|
f'e.g.: {", ".join(sample)}')
|
||||||
|
|
||||||
|
return _push_bans(settings, candidates, 'auto_404_flood', make_reason, None, window_start)
|
||||||
|
|
||||||
|
|
||||||
def _k8s_logs(settings: NginxSettings) -> str | None:
|
def _k8s_logs(settings: NginxSettings) -> str | None:
|
||||||
"""Fetch pod logs via the Kubernetes Python client (works in-cluster and locally)."""
|
"""Fetch pod logs via the Kubernetes Python client (works in-cluster and locally)."""
|
||||||
since = settings.fetch_interval_seconds + _OVERLAP
|
since = settings.fetch_interval_seconds + _OVERLAP
|
||||||
@@ -207,6 +420,14 @@ def cleanup_old_logs(days: int = 7):
|
|||||||
logger.info('nginxmon: pruned %d old log entries (>%d days)', deleted, days)
|
logger.info('nginxmon: pruned %d old log entries (>%d days)', deleted, days)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_private_ip(ip: str) -> bool:
|
||||||
|
"""Return True for RFC-1918, loopback, link-local, and other private ranges."""
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address(ip).is_private
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _build_excluder(excluded_ips_text: str):
|
def _build_excluder(excluded_ips_text: str):
|
||||||
"""
|
"""
|
||||||
Build and return a callable(ip: str) -> bool that returns True when the
|
Build and return a callable(ip: str) -> bool that returns True when the
|
||||||
@@ -251,49 +472,3 @@ def _build_excluder(excluded_ips_text: str):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
return _is_excluded
|
return _is_excluded
|
||||||
|
|
||||||
|
|
||||||
def _build_excluder(excluded_ips_text: str):
|
|
||||||
"""
|
|
||||||
Build and return a callable(ip: str) -> bool that returns True when the
|
|
||||||
given IP should be excluded from ingestion.
|
|
||||||
|
|
||||||
Supported entry formats (one per line):
|
|
||||||
192.168.1.218 exact IP
|
|
||||||
192.168.1.x wildcard — any IP whose first 3 octets match
|
|
||||||
192.168.1.0/24 CIDR range
|
|
||||||
"""
|
|
||||||
exact: set[str] = set()
|
|
||||||
wildcards: list[str] = [] # prefixes like '192.168.1.'
|
|
||||||
networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
|
|
||||||
|
|
||||||
for raw_line in (excluded_ips_text or '').splitlines():
|
|
||||||
entry = raw_line.strip()
|
|
||||||
if not entry or entry.startswith('#'):
|
|
||||||
continue
|
|
||||||
if entry.endswith('.x') or entry.endswith('.*'):
|
|
||||||
# Wildcard: treat everything before .x as a prefix
|
|
||||||
wildcards.append(entry[:-1]) # keep trailing '.'
|
|
||||||
elif '/' in entry:
|
|
||||||
try:
|
|
||||||
networks.append(ipaddress.ip_network(entry, strict=False))
|
|
||||||
except ValueError:
|
|
||||||
logger.warning('nginxmon: invalid CIDR in excluded_ips: %r', entry)
|
|
||||||
else:
|
|
||||||
exact.add(entry)
|
|
||||||
|
|
||||||
def _is_excluded(ip: str) -> bool:
|
|
||||||
if ip in exact:
|
|
||||||
return True
|
|
||||||
for prefix in wildcards:
|
|
||||||
if ip.startswith(prefix):
|
|
||||||
return True
|
|
||||||
if networks:
|
|
||||||
try:
|
|
||||||
addr = ipaddress.ip_address(ip)
|
|
||||||
return any(addr in net for net in networks)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
return _is_excluded
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class NginxSettingsForm(forms.ModelForm):
|
|||||||
'log_file_path',
|
'log_file_path',
|
||||||
'enabled',
|
'enabled',
|
||||||
'excluded_ips',
|
'excluded_ips',
|
||||||
|
'auto_ban_enabled',
|
||||||
|
'auto_ban_threshold',
|
||||||
|
'auto_ban_window_hours',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'excluded_ips': forms.Textarea(attrs={
|
'excluded_ips': forms.Textarea(attrs={
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-04-01 10:08
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('nginxmon', '0003_add_excluded_ips'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='nginxaccesslog',
|
||||||
|
name='note',
|
||||||
|
field=models.TextField(blank=True, default=''),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='nginxsettings',
|
||||||
|
name='auto_ban_enabled',
|
||||||
|
field=models.BooleanField(default=True, help_text='Automatically ban IPs that repeatedly probe auth endpoints.'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='nginxsettings',
|
||||||
|
name='auto_ban_threshold',
|
||||||
|
field=models.IntegerField(default=5, help_text='Number of auth-probe requests from one IP within the window before auto-ban.'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='nginxsettings',
|
||||||
|
name='auto_ban_window_hours',
|
||||||
|
field=models.IntegerField(default=24, help_text='Rolling window (hours) used to count auth-probe requests.'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-04-01 10:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('nginxmon', '0004_auto_ban_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BannedIP',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('ip', models.GenericIPAddressField(db_index=True, unique=True)),
|
||||||
|
('ban_source', models.CharField(choices=[('manual', 'Manual'), ('auto_auth_probe', 'Auto: Auth Probe'), ('auto_php_scan', 'Auto: PHP Scanner'), ('auto_404_flood', 'Auto: 404 Flood')], default='manual', max_length=30)),
|
||||||
|
('reason', models.TextField()),
|
||||||
|
('request_count', models.IntegerField(default=0)),
|
||||||
|
('banned_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('country', models.CharField(blank=True, max_length=100)),
|
||||||
|
('city', models.CharField(blank=True, max_length=100)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-banned_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -39,6 +39,18 @@ class NginxSettings(models.Model):
|
|||||||
'Matching requests will not be ingested.'
|
'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:
|
class Meta:
|
||||||
verbose_name = 'Nginx Settings'
|
verbose_name = 'Nginx Settings'
|
||||||
@@ -107,6 +119,8 @@ class NginxAccessLog(models.Model):
|
|||||||
upstream_response_time = models.FloatField(null=True, blank=True)
|
upstream_response_time = models.FloatField(null=True, blank=True)
|
||||||
upstream_status = models.IntegerField(null=True, blank=True)
|
upstream_status = models.IntegerField(null=True, blank=True)
|
||||||
request_id = models.CharField(max_length=100, blank=True, db_index=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)
|
# Geo (populated after insert)
|
||||||
country = models.CharField(max_length=100, blank=True)
|
country = models.CharField(max_length=100, blank=True)
|
||||||
country_code = models.CharField(max_length=10, blank=True)
|
country_code = models.CharField(max_length=10, blank=True)
|
||||||
@@ -176,3 +190,30 @@ class ThreatAlert(models.Model):
|
|||||||
@property
|
@property
|
||||||
def error_rate(self):
|
def error_rate(self):
|
||||||
return self.error_count / self.request_count if self.request_count else 0
|
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)
|
||||||
|
country = models.CharField(max_length=100, blank=True)
|
||||||
|
city = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-banned_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.ip} [{self.get_ban_source_display()}] @ {self.banned_at:%Y-%m-%d %H:%M}'
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-1.5 text-right font-mono text-gray-500 hidden lg:table-cell">{{ log.request_time|floatformat:3 }}s</td>
|
<td class="px-3 py-1.5 text-right font-mono text-gray-500 hidden lg:table-cell">{{ log.request_time|floatformat:3 }}s</td>
|
||||||
|
<td class="px-3 py-1.5 hidden xl:table-cell">
|
||||||
|
{% if log.note %}<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-red-50 text-red-700 text-xs font-medium" title="{{ log.note }}"><i class="fas fa-ban text-red-500"></i> {{ log.note }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">No logs yet — use "Fetch Now" or wait for the scheduler.</td></tr>
|
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">No logs yet — use "Fetch Now" or wait for the scheduler.</td></tr>
|
||||||
|
|||||||
@@ -251,6 +251,7 @@
|
|||||||
<th data-sort="service" onclick="setSort('service')" class="px-3 py-2 text-left text-gray-500 font-semibold cursor-pointer hover:bg-gray-100 hidden md:table-cell">Service <span class="sort-icon text-gray-300 ml-0.5">⇅</span></th>
|
<th data-sort="service" onclick="setSort('service')" class="px-3 py-2 text-left text-gray-500 font-semibold cursor-pointer hover:bg-gray-100 hidden md:table-cell">Service <span class="sort-icon text-gray-300 ml-0.5">⇅</span></th>
|
||||||
<th data-sort="status" onclick="setSort('status')" class="px-3 py-2 text-right text-gray-500 font-semibold cursor-pointer hover:bg-gray-100">Status <span class="sort-icon text-gray-300 ml-0.5">⇅</span></th>
|
<th data-sort="status" onclick="setSort('status')" class="px-3 py-2 text-right text-gray-500 font-semibold cursor-pointer hover:bg-gray-100">Status <span class="sort-icon text-gray-300 ml-0.5">⇅</span></th>
|
||||||
<th data-sort="time" onclick="setSort('time')" class="px-3 py-2 text-right text-gray-500 font-semibold cursor-pointer hover:bg-gray-100 hidden lg:table-cell">Time(s) <span class="sort-icon text-gray-300 ml-0.5">⇅</span></th>
|
<th data-sort="time" onclick="setSort('time')" class="px-3 py-2 text-right text-gray-500 font-semibold cursor-pointer hover:bg-gray-100 hidden lg:table-cell">Time(s) <span class="sort-icon text-gray-300 ml-0.5">⇅</span></th>
|
||||||
|
<th class="px-3 py-2 text-left text-gray-500 font-semibold hidden xl:table-cell">Note</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="live-log-body" class="divide-y divide-gray-50">
|
<tbody id="live-log-body" class="divide-y divide-gray-50">
|
||||||
@@ -260,6 +261,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Banned IPs ─────────────────────────────────────────────────────── -->
|
||||||
|
<div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<div class="px-6 py-4 bg-red-50 border-b border-red-200 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-ban text-red-500"></i>
|
||||||
|
<h2 class="text-sm font-semibold text-red-800">Banned IPs</h2>
|
||||||
|
<span class="ml-1 px-2 py-0.5 rounded-full bg-red-100 text-red-700 text-xs font-bold">{{ banned_ips|length }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if banned_ips %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-xs">
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-gray-500 font-semibold">IP</th>
|
||||||
|
<th class="px-4 py-2 text-left text-gray-500 font-semibold hidden sm:table-cell">Location</th>
|
||||||
|
<th class="px-4 py-2 text-left text-gray-500 font-semibold">Source</th>
|
||||||
|
<th class="px-4 py-2 text-left text-gray-500 font-semibold">Reason</th>
|
||||||
|
<th class="px-4 py-2 text-right text-gray-500 font-semibold">Requests</th>
|
||||||
|
<th class="px-4 py-2 text-left text-gray-500 font-semibold hidden md:table-cell">Banned At</th>
|
||||||
|
<th class="px-4 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-50">
|
||||||
|
{% for ban in banned_ips %}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-2 font-mono font-medium text-red-700">
|
||||||
|
<button class="hover:underline" onclick="setFilter('ip','{{ ban.ip }}')">{{ ban.ip }}</button>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-gray-500 hidden sm:table-cell">
|
||||||
|
{% if ban.city or ban.country %}{{ ban.city }}{% if ban.city and ban.country %}, {% endif %}{{ ban.country }}{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded text-xs font-semibold
|
||||||
|
{% if ban.ban_source == 'manual' %}bg-gray-100 text-gray-700
|
||||||
|
{% elif ban.ban_source == 'auto_auth_probe' %}bg-purple-100 text-purple-700
|
||||||
|
{% elif ban.ban_source == 'auto_php_scan' %}bg-orange-100 text-orange-700
|
||||||
|
{% else %}bg-yellow-100 text-yellow-700{% endif %}">
|
||||||
|
{{ ban.get_ban_source_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-gray-600 max-w-sm truncate" title="{{ ban.reason }}">{{ ban.reason }}</td>
|
||||||
|
<td class="px-4 py-2 text-right font-mono text-gray-500">{{ ban.request_count }}</td>
|
||||||
|
<td class="px-4 py-2 text-gray-400 hidden md:table-cell whitespace-nowrap">{{ ban.banned_at|date:"m/d H:i" }}</td>
|
||||||
|
<td class="px-4 py-2 text-right">
|
||||||
|
<form method="post" action="{% url 'nginxmon-unban' ban.pk %}" class="inline"
|
||||||
|
onsubmit="return confirm('Unban {{ ban.ip }}?')">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit"
|
||||||
|
class="text-xs text-gray-400 hover:text-red-600 hover:underline">Unban</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-6 py-8 text-center text-gray-400 text-sm">No IPs banned yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ urlpatterns = [
|
|||||||
path('actions/fetch/', views.TriggerFetchView.as_view(), name='nginxmon-fetch'),
|
path('actions/fetch/', views.TriggerFetchView.as_view(), name='nginxmon-fetch'),
|
||||||
path('actions/detect/', views.TriggerDetectView.as_view(), name='nginxmon-detect'),
|
path('actions/detect/', views.TriggerDetectView.as_view(), name='nginxmon-detect'),
|
||||||
path('alert/<int:pk>/dismiss/', views.DismissAlertView.as_view(), name='nginxmon-dismiss-alert'),
|
path('alert/<int:pk>/dismiss/', views.DismissAlertView.as_view(), name='nginxmon-dismiss-alert'),
|
||||||
|
path('ban/<int:pk>/unban/', views.UnbanIPView.as_view(), name='nginxmon-unban'),
|
||||||
|
|
||||||
# HTMX partials & APIs
|
# HTMX partials & APIs
|
||||||
path('api/live-logs/', views.LiveLogsPartialView.as_view(), name='nginxmon-live-logs'),
|
path('api/live-logs/', views.LiveLogsPartialView.as_view(), name='nginxmon-live-logs'),
|
||||||
|
|||||||
+24
-1
@@ -16,7 +16,7 @@ from django.views.generic import CreateView, DeleteView, TemplateView, UpdateVie
|
|||||||
from .detector import detect_threats as run_detect
|
from .detector import detect_threats as run_detect
|
||||||
from .fetcher import fetch_and_store, ingest_raw
|
from .fetcher import fetch_and_store, ingest_raw
|
||||||
from .forms import NginxSettingsForm, NginxAlertProfileForm, PasteLogsForm
|
from .forms import NginxSettingsForm, NginxAlertProfileForm, PasteLogsForm
|
||||||
from .models import IPGeoCache, NginxAccessLog, NginxAlertProfile, NginxSettings, ThreatAlert
|
from .models import BannedIP, IPGeoCache, NginxAccessLog, NginxAlertProfile, NginxSettings, ThreatAlert
|
||||||
from .notifications import send_test_telegram
|
from .notifications import send_test_telegram
|
||||||
from .tasks import start_fetch_job, stop_fetch_job, schedule_profile, unschedule_profile
|
from .tasks import start_fetch_job, stop_fetch_job, schedule_profile, unschedule_profile
|
||||||
|
|
||||||
@@ -109,6 +109,7 @@ class DashboardView(TemplateView):
|
|||||||
)
|
)
|
||||||
active_alerts = ThreatAlert.objects.filter(dismissed=False).count()
|
active_alerts = ThreatAlert.objects.filter(dismissed=False).count()
|
||||||
profiles = NginxAlertProfile.objects.filter(enabled=True)
|
profiles = NginxAlertProfile.objects.filter(enabled=True)
|
||||||
|
banned_ips = BannedIP.objects.all()[:100]
|
||||||
|
|
||||||
ctx.update({
|
ctx.update({
|
||||||
'nginx_settings': NginxSettings.get(),
|
'nginx_settings': NginxSettings.get(),
|
||||||
@@ -119,6 +120,7 @@ class DashboardView(TemplateView):
|
|||||||
'top_ips': top_ips,
|
'top_ips': top_ips,
|
||||||
'active_alerts': active_alerts,
|
'active_alerts': active_alerts,
|
||||||
'profiles': profiles,
|
'profiles': profiles,
|
||||||
|
'banned_ips': banned_ips,
|
||||||
'status_dist_json': json.dumps(status_dist),
|
'status_dist_json': json.dumps(status_dist),
|
||||||
'top_paths_json': json.dumps(top_paths),
|
'top_paths_json': json.dumps(top_paths),
|
||||||
'current_range': range_key,
|
'current_range': range_key,
|
||||||
@@ -302,6 +304,27 @@ class DismissAlertView(View):
|
|||||||
return redirect('nginxmon-dashboard')
|
return redirect('nginxmon-dashboard')
|
||||||
|
|
||||||
|
|
||||||
|
class UnbanIPView(View):
|
||||||
|
"""Remove an IP from both the BannedIP table and the ConfigMap block list."""
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
ban = get_object_or_404(BannedIP, pk=pk)
|
||||||
|
ip = ban.ip
|
||||||
|
try:
|
||||||
|
from links.mini_apps_views import _read_blocked_ips, _write_blocked_ips
|
||||||
|
blocked = _read_blocked_ips()
|
||||||
|
if ip in blocked:
|
||||||
|
blocked.remove(ip)
|
||||||
|
_write_blocked_ips(blocked)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('nginxmon unban: could not update ConfigMap for %s: %s', ip, exc)
|
||||||
|
ban.delete()
|
||||||
|
# Remove notes from log rows for this IP
|
||||||
|
NginxAccessLog.objects.filter(remote_addr=ip).update(note='')
|
||||||
|
messages.success(request, f'{ip} has been unbanned.')
|
||||||
|
return redirect('nginxmon-dashboard')
|
||||||
|
|
||||||
|
|
||||||
# ── Chart & ingest API ────────────────────────────────────────────────────────
|
# ── Chart & ingest API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class ChartDataView(View):
|
class ChartDataView(View):
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import django, os, sys
|
||||||
|
os.environ['DJANGO_SETTINGS_MODULE'] = 'core.settings'
|
||||||
|
sys.path.insert(0, '/Users/junv/code/links')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from nginxmon.models import NginxAccessLog
|
||||||
|
from django.db.models import Count
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
since = timezone.now() - timedelta(hours=24)
|
||||||
|
ips = [
|
||||||
|
"210.231.178.225","139.162.37.203","20.116.48.67","4.204.200.32",
|
||||||
|
"139.162.37.41","115.171.57.239","20.151.201.236","74.248.155.72",
|
||||||
|
"130.12.98.27","112.1.105.92","20.220.232.240","172.190.142.176",
|
||||||
|
]
|
||||||
|
|
||||||
|
for ip in ips:
|
||||||
|
paths = list(NginxAccessLog.objects.filter(remote_addr=ip, timestamp__gte=since)
|
||||||
|
.values("request_uri","status").annotate(cnt=Count("id")).order_by("-cnt")[:5])
|
||||||
|
statuses = list(NginxAccessLog.objects.filter(remote_addr=ip, timestamp__gte=since)
|
||||||
|
.values("status").annotate(cnt=Count("id")).order_by("-cnt"))
|
||||||
|
ss = ", ".join(str(r["status"])+"("+str(r["cnt"])+")" for r in statuses)
|
||||||
|
print("-- "+ip+" "+ss)
|
||||||
|
for p in paths:
|
||||||
|
print(" ["+str(p["status"])+"] "+str(p["cnt"])+"x "+p["request_uri"][:80])
|
||||||
|
print("")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import django, os, sys
|
||||||
|
os.environ['DJANGO_SETTINGS_MODULE'] = 'core.settings'
|
||||||
|
sys.path.insert(0, '/Users/junv/code/links')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from nginxmon.models import NginxSettings
|
||||||
|
from nginxmon.fetcher import auto_ban_php_scanners, auto_ban_404_flood, auto_ban_auth_scanners
|
||||||
|
|
||||||
|
settings = NginxSettings.get()
|
||||||
|
print("Running auto_ban_php_scanners...")
|
||||||
|
banned = auto_ban_php_scanners(settings)
|
||||||
|
print(f" PHP scanners banned: {banned}")
|
||||||
|
|
||||||
|
print("Running auto_ban_404_flood...")
|
||||||
|
banned = auto_ban_404_flood(settings)
|
||||||
|
print(f" 404 flood banned: {banned}")
|
||||||
|
|
||||||
|
print("Running auto_ban_auth_scanners...")
|
||||||
|
banned = auto_ban_auth_scanners(settings)
|
||||||
|
print(f" Auth probe banned: {banned}")
|
||||||
|
|
||||||
|
from nginxmon.models import BannedIP
|
||||||
|
print(f"\nTotal BannedIP records: {BannedIP.objects.count()}")
|
||||||
|
for b in BannedIP.objects.all():
|
||||||
|
print(f" {b.ip:20s} [{b.get_ban_source_display()}] {b.reason[:70]}")
|
||||||
Reference in New Issue
Block a user