Files
2026-04-01 21:33:07 +11:00

475 lines
16 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.
"""
Fetch nginx ingress logs — from the Kubernetes API (production) or a local file (dev/testing).
Deduplicates by request_id so overlapping windows don't double-insert.
"""
import ipaddress
import logging
from datetime import timedelta
from django.db import transaction
from django.utils import timezone as dj_tz
from .models import NginxSettings, NginxAccessLog
from .parser import parse_lines
from .geo import enrich_geo_batch
logger = logging.getLogger(__name__)
_OVERLAP = 60 # extra seconds to avoid missing entries near boundaries
def fetch_and_store() -> int:
"""
Pull new log lines, parse, deduplicate, and save.
Returns the number of new rows inserted.
"""
settings = NginxSettings.get()
if not settings.enabled:
return 0
raw = _read_file(settings) if settings.log_file_path else _k8s_logs(settings)
if raw is None:
return 0
entries = parse_lines(raw)
if not entries:
_touch(settings)
return 0
excluder = _build_excluder(settings.excluded_ips)
since_seconds = settings.fetch_interval_seconds + _OVERLAP
existing_ids = set(
NginxAccessLog.objects.filter(
timestamp__gte=dj_tz.now() - timedelta(seconds=since_seconds + 10),
).values_list('request_id', flat=True)
)
new_logs, seen = [], set()
for e in entries:
if excluder(e['remote_addr']):
continue
key = e['request_id'] or (
f"{e['timestamp'].isoformat()}|{e['remote_addr']}|{e['request_uri']}"
)
if key in existing_ids or key in seen:
continue
seen.add(key)
new_logs.append(NginxAccessLog(
timestamp=e['timestamp'],
remote_addr=e['remote_addr'],
method=e['method'],
request_uri=e['request_uri'],
protocol=e['protocol'],
status=e['status'],
body_bytes_sent=e['body_bytes_sent'],
http_referer=e['http_referer'],
http_user_agent=e['http_user_agent'],
request_length=e['request_length'],
request_time=e['request_time'],
service=e['service'],
upstream_addr=e['upstream_addr'],
upstream_response_time=e['upstream_response_time'],
upstream_status=e['upstream_status'],
request_id=e['request_id'],
))
if new_logs:
with transaction.atomic():
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)
def ingest_raw(text: str) -> int:
"""
Parse and store log lines from a raw string (used by the paste-logs UI
and the management command). Returns the number of new rows inserted.
"""
entries = parse_lines(text)
if not entries:
return 0
settings = NginxSettings.get()
excluder = _build_excluder(settings.excluded_ips)
existing_ids = set(
NginxAccessLog.objects.values_list('request_id', flat=True)
)
new_logs, seen = [], set()
for e in entries:
if excluder(e['remote_addr']):
continue
key = e['request_id'] or (
f"{e['timestamp'].isoformat()}|{e['remote_addr']}|{e['request_uri']}"
)
if key in existing_ids or key in seen:
continue
seen.add(key)
new_logs.append(NginxAccessLog(
timestamp=e['timestamp'],
remote_addr=e['remote_addr'],
method=e['method'],
request_uri=e['request_uri'],
protocol=e['protocol'],
status=e['status'],
body_bytes_sent=e['body_bytes_sent'],
http_referer=e['http_referer'],
http_user_agent=e['http_user_agent'],
request_length=e['request_length'],
request_time=e['request_time'],
service=e['service'],
upstream_addr=e['upstream_addr'],
upstream_response_time=e['upstream_response_time'],
upstream_status=e['upstream_status'],
request_id=e['request_id'],
))
if new_logs:
with transaction.atomic():
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
try:
from kubernetes import client, config
try:
config.load_incluster_config()
except config.ConfigException:
config.load_kube_config()
v1 = client.CoreV1Api()
pods = v1.list_namespaced_pod(
namespace=settings.namespace,
label_selector=settings.pod_label,
)
if not pods.items:
logger.warning('nginxmon: no pods found for selector %r in %r',
settings.pod_label, settings.namespace)
return None
lines = []
for pod in pods.items:
pod_name = pod.metadata.name
try:
log_text = v1.read_namespaced_pod_log(
name=pod_name,
namespace=settings.namespace,
container=settings.container,
since_seconds=since,
)
for line in log_text.splitlines():
if line.strip():
# Prepend "pod container" so the parser matches its expected format
lines.append(f'{pod_name} {settings.container} {line}')
except Exception as exc:
logger.warning('nginxmon: could not read logs from pod %s: %s', pod_name, exc)
return '\n'.join(lines)
except ImportError:
logger.error('nginxmon: kubernetes package not installed — run: uv add kubernetes')
return None
except Exception as exc:
logger.error('nginxmon: k8s logs error: %s', exc)
return None
def _read_file(settings: NginxSettings) -> str | None:
try:
with open(settings.log_file_path, 'r', encoding='utf-8', errors='replace') as f:
return f.read()
except FileNotFoundError:
logger.error('nginxmon: log file not found: %s', settings.log_file_path)
return None
except Exception as exc:
logger.error('nginxmon: error reading log file: %s', exc)
return None
def _touch(settings: NginxSettings):
NginxSettings.objects.filter(pk=settings.pk).update(last_fetch_at=dj_tz.now())
def cleanup_old_logs(days: int = 7):
from datetime import timedelta
cutoff = dj_tz.now() - timedelta(days=days)
deleted, _ = NginxAccessLog.objects.filter(timestamp__lt=cutoff).delete()
if deleted:
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
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: everything before .x/.* becomes a dotted 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