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/
.build/
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)
logger.info('nginxmon: inserted %d new log entries', len(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)
return len(new_logs)
@@ -132,10 +136,219 @@ def ingest_raw(text: str) -> int:
NginxAccessLog.objects.bulk_create(new_logs, ignore_conflicts=True)
logger.info('nginxmon: ingested %d log entries from raw text', len(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)
# 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:
"""Fetch pod logs via the Kubernetes Python client (works in-cluster and locally)."""
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)
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):
"""
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 _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',
'enabled',
'excluded_ips',
'auto_ban_enabled',
'auto_ban_threshold',
'auto_ban_window_hours',
]
widgets = {
'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.'
),
)
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'
@@ -107,6 +119,8 @@ class NginxAccessLog(models.Model):
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)
@@ -176,3 +190,30 @@ class ThreatAlert(models.Model):
@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)
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>
</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>
{% 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>
@@ -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="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 class="px-3 py-2 text-left text-gray-500 font-semibold hidden xl:table-cell">Note</th>
</tr>
</thead>
<tbody id="live-log-body" class="divide-y divide-gray-50">
@@ -260,6 +261,68 @@
</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>
<script>
+1
View File
@@ -18,6 +18,7 @@ urlpatterns = [
path('actions/fetch/', views.TriggerFetchView.as_view(), name='nginxmon-fetch'),
path('actions/detect/', views.TriggerDetectView.as_view(), name='nginxmon-detect'),
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
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 .fetcher import fetch_and_store, ingest_raw
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 .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()
profiles = NginxAlertProfile.objects.filter(enabled=True)
banned_ips = BannedIP.objects.all()[:100]
ctx.update({
'nginx_settings': NginxSettings.get(),
@@ -119,6 +120,7 @@ class DashboardView(TemplateView):
'top_ips': top_ips,
'active_alerts': active_alerts,
'profiles': profiles,
'banned_ips': banned_ips,
'status_dist_json': json.dumps(status_dist),
'top_paths_json': json.dumps(top_paths),
'current_range': range_key,
@@ -302,6 +304,27 @@ class DismissAlertView(View):
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 ────────────────────────────────────────────────────────
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]}")