Add auto ban feature!

This commit is contained in:
2026-04-01 21:33:07 +11:00
parent 0d489e658e
commit e89825a114
12 changed files with 472 additions and 47 deletions
+2
View File
@@ -102,3 +102,5 @@ DerivedData/
.swiftpm/ .swiftpm/
.build/ .build/
data/db.sqlite3 data/db.sqlite3
.playwright-mcp/
+221 -46
View File
@@ -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
+3
View File
@@ -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'],
},
),
]
+41
View File
@@ -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>
+1
View File
@@ -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
View File
@@ -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):
+27
View File
@@ -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("")
+25
View File
@@ -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]}")