Add Loki and Sentinel utility classes for web API endpoints

- Implemented LokiUtils class with GET and POST endpoints for managing scripts, jobs, and payloads.
- Added SentinelUtils class with GET and POST endpoints for managing events, rules, devices, and notifications.
- Both classes include error handling and JSON response formatting.
This commit is contained in:
infinition
2026-03-14 22:33:10 +01:00
parent eb20b168a6
commit aac77a3e76
525 changed files with 29400 additions and 13136 deletions

593
sentinel.py Normal file
View File

@@ -0,0 +1,593 @@
"""
Sentinel — Bjorn Network Watchdog Engine.
Lightweight background thread that monitors network state changes
and fires configurable alerts via rules. Resource-friendly: yields
to the orchestrator when actions are running.
Detection modules:
- new_device: Never-seen MAC appears on the network
- device_join: Known device comes back online (alive 0→1)
- device_leave: Known device goes offline (alive 1→0)
- arp_spoof: Same IP claimed by multiple MACs (ARP cache conflict)
- port_change: Host ports changed since last snapshot
- service_change: New service detected on known host
- rogue_dhcp: Multiple DHCP servers on the network
- dns_anomaly: DNS response pointing to unexpected IP
- mac_flood: Sudden burst of new MACs (possible MAC flooding attack)
"""
import json
import logging
import subprocess
import threading
import time
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Set
from logger import Logger
logger = Logger(name="sentinel", level=logging.DEBUG)
# Severity levels
SEV_INFO = "info"
SEV_WARNING = "warning"
SEV_CRITICAL = "critical"
class SentinelEngine:
"""
Main Sentinel watchdog. Runs a scan loop on a configurable interval.
All checks read from the existing Bjorn DB — zero extra network traffic.
"""
def __init__(self, shared_data):
self.shared_data = shared_data
self.db = shared_data.db
self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self._running = False
# In-memory state for diff-based detection
self._known_macs: Set[str] = set() # MACs we've ever seen
self._alive_macs: Set[str] = set() # Currently alive MACs
self._port_snapshot: Dict[str, str] = {} # mac → ports string
self._arp_cache: Dict[str, str] = {} # ip → mac mapping
self._last_check = 0.0
self._initialized = False
# Notifier registry
self._notifiers: Dict[str, Any] = {}
# ── Lifecycle ───────────────────────────────────────────────────────
@property
def enabled(self) -> bool:
return bool(getattr(self.shared_data, 'sentinel_enabled', False))
@property
def interval(self) -> int:
return max(10, int(getattr(self.shared_data, 'sentinel_interval', 30)))
def start(self):
if self._running:
return
if not self.enabled:
logger.info("Sentinel is disabled in config, not starting.")
return
self._stop_event.clear()
self._running = True
self._thread = threading.Thread(
target=self._loop, name="Sentinel", daemon=True
)
self._thread.start()
logger.info("Sentinel engine started (interval=%ds)", self.interval)
def stop(self):
if not self._running:
return
self._stop_event.set()
self._running = False
if self._thread:
self._thread.join(timeout=10)
self._thread = None
logger.info("Sentinel engine stopped.")
def register_notifier(self, name: str, notifier):
"""Register a notification dispatcher (discord, email, webhook, etc.)."""
self._notifiers[name] = notifier
# ── Main loop ───────────────────────────────────────────────────────
def _loop(self):
# Give Bjorn a moment to start up
self._stop_event.wait(5)
while not self._stop_event.is_set():
try:
if not self.enabled:
self._stop_event.wait(30)
continue
# Resource-friendly: skip if orchestrator is busy with actions
running_count = self._count_running_actions()
if running_count > 2:
logger.debug("Sentinel yielding — %d actions running", running_count)
self._stop_event.wait(min(self.interval, 15))
continue
self._run_checks()
except Exception as e:
logger.error("Sentinel loop error: %s", e)
self._stop_event.wait(self.interval)
def _count_running_actions(self) -> int:
try:
rows = self.db.query(
"SELECT COUNT(*) AS c FROM action_queue WHERE status = 'running'"
)
return int(rows[0].get("c", 0)) if rows else 0
except Exception:
return 0
# ── Detection engine ────────────────────────────────────────────────
def _run_checks(self):
"""Execute all detection modules against current DB state."""
try:
hosts = self.db.query("SELECT * FROM hosts") or []
except Exception as e:
logger.debug("Sentinel can't read hosts: %s", e)
return
current_macs = set()
current_alive = set()
current_ports: Dict[str, str] = {}
for h in hosts:
mac = (h.get("mac_address") or "").lower()
if not mac:
continue
current_macs.add(mac)
if h.get("alive"):
current_alive.add(mac)
current_ports[mac] = h.get("ports") or ""
if not self._initialized:
# First run: snapshot state without firing alerts
self._known_macs = set(current_macs)
self._alive_macs = set(current_alive)
self._port_snapshot = dict(current_ports)
self._build_arp_cache(hosts)
self._initialized = True
logger.info("Sentinel initialized with %d known devices", len(self._known_macs))
return
# 1) New device detection
new_macs = current_macs - self._known_macs
for mac in new_macs:
host = next((h for h in hosts if (h.get("mac_address") or "").lower() == mac), {})
ip = (host.get("ips") or "").split(";")[0]
hostname = (host.get("hostnames") or "").split(";")[0] or "Unknown"
vendor = host.get("vendor") or "Unknown"
self._fire_event(
"new_device", SEV_WARNING,
f"New device: {hostname} ({vendor})",
f"MAC: {mac} | IP: {ip} | Vendor: {vendor}",
mac=mac, ip=ip,
meta={"hostname": hostname, "vendor": vendor}
)
# 2) Device join (came online)
joined = current_alive - self._alive_macs
for mac in joined:
if mac in new_macs:
continue # Already reported as new
host = next((h for h in hosts if (h.get("mac_address") or "").lower() == mac), {})
ip = (host.get("ips") or "").split(";")[0]
hostname = (host.get("hostnames") or "").split(";")[0] or mac
self._fire_event(
"device_join", SEV_INFO,
f"Device online: {hostname}",
f"MAC: {mac} | IP: {ip}",
mac=mac, ip=ip
)
# 3) Device leave (went offline)
left = self._alive_macs - current_alive
for mac in left:
host = next((h for h in hosts if (h.get("mac_address") or "").lower() == mac), {})
hostname = (host.get("hostnames") or "").split(";")[0] or mac
self._fire_event(
"device_leave", SEV_INFO,
f"Device offline: {hostname}",
f"MAC: {mac}",
mac=mac
)
# 4) Port changes on known hosts
for mac in current_macs & self._known_macs:
old_ports = self._port_snapshot.get(mac, "")
new_ports = current_ports.get(mac, "")
if old_ports != new_ports and old_ports and new_ports:
old_set = set(old_ports.split(";")) if old_ports else set()
new_set = set(new_ports.split(";")) if new_ports else set()
opened = new_set - old_set
closed = old_set - new_set
if opened or closed:
host = next((h for h in hosts if (h.get("mac_address") or "").lower() == mac), {})
hostname = (host.get("hostnames") or "").split(";")[0] or mac
parts = []
if opened:
parts.append(f"Opened: {', '.join(sorted(opened))}")
if closed:
parts.append(f"Closed: {', '.join(sorted(closed))}")
self._fire_event(
"port_change", SEV_WARNING,
f"Port change on {hostname}",
" | ".join(parts),
mac=mac,
meta={"opened": list(opened), "closed": list(closed)}
)
# 5) ARP spoofing detection
self._check_arp_spoofing(hosts)
# 6) MAC flood detection
if len(new_macs) >= 5:
self._fire_event(
"mac_flood", SEV_CRITICAL,
f"MAC flood: {len(new_macs)} new devices in one cycle",
f"MACs: {', '.join(list(new_macs)[:10])}",
meta={"count": len(new_macs), "macs": list(new_macs)[:20]}
)
# Update state snapshots
self._known_macs = current_macs
self._alive_macs = current_alive
self._port_snapshot = current_ports
def _build_arp_cache(self, hosts: List[Dict]):
"""Build initial ARP cache from host data."""
self._arp_cache = {}
for h in hosts:
mac = (h.get("mac_address") or "").lower()
ips = (h.get("ips") or "").split(";")
for ip in ips:
ip = ip.strip()
if ip:
self._arp_cache[ip] = mac
try:
self.db._base.execute(
"""INSERT INTO sentinel_arp_cache (mac_address, ip_address)
VALUES (?, ?)
ON CONFLICT(mac_address, ip_address)
DO UPDATE SET last_seen = CURRENT_TIMESTAMP""",
(mac, ip)
)
except Exception:
pass
def _check_arp_spoofing(self, hosts: List[Dict]):
"""Detect IP claimed by different MAC than previously seen."""
for h in hosts:
mac = (h.get("mac_address") or "").lower()
if not mac or not h.get("alive"):
continue
ips = (h.get("ips") or "").split(";")
for ip in ips:
ip = ip.strip()
if not ip:
continue
prev_mac = self._arp_cache.get(ip)
if prev_mac and prev_mac != mac:
hostname = (h.get("hostnames") or "").split(";")[0] or mac
self._fire_event(
"arp_spoof", SEV_CRITICAL,
f"ARP Spoof: {ip} changed from {prev_mac} to {mac}",
f"IP {ip} was bound to {prev_mac}, now claimed by {mac} ({hostname}). "
f"Possible ARP spoofing / MITM attack.",
mac=mac, ip=ip,
meta={"old_mac": prev_mac, "new_mac": mac}
)
self._arp_cache[ip] = mac
try:
self.db._base.execute(
"""INSERT INTO sentinel_arp_cache (mac_address, ip_address)
VALUES (?, ?)
ON CONFLICT(mac_address, ip_address)
DO UPDATE SET last_seen = CURRENT_TIMESTAMP""",
(mac, ip)
)
except Exception:
pass
# ── Event firing & rule engine ──────────────────────────────────────
def _fire_event(self, event_type: str, severity: str, title: str,
details: str = "", mac: str = "", ip: str = "",
meta: Optional[Dict] = None):
"""Check rules, store event, dispatch notifications."""
try:
# Check if any enabled rule matches
rules = self.db.query(
"SELECT * FROM sentinel_rules WHERE enabled = 1 AND trigger_type = ?",
(event_type,)
) or []
if not rules:
# No rules for this event type — still log but don't notify
self._store_event(event_type, severity, title, details, mac, ip, meta)
return
for rule in rules:
# Check cooldown
last_fired = rule.get("last_fired")
cooldown = int(rule.get("cooldown_s", 60))
if last_fired and cooldown > 0:
try:
lf = datetime.fromisoformat(last_fired)
if datetime.now() - lf < timedelta(seconds=cooldown):
continue
except Exception:
pass
# Check conditions (AND/OR logic)
conditions = rule.get("conditions", "{}")
if isinstance(conditions, str):
try:
conditions = json.loads(conditions)
except Exception:
conditions = {}
logic = rule.get("logic", "AND")
if conditions and not self._evaluate_conditions(conditions, logic,
mac=mac, ip=ip, meta=meta):
continue
# Store event
self._store_event(event_type, severity, title, details, mac, ip, meta)
# Update rule last_fired
try:
self.db.execute(
"UPDATE sentinel_rules SET last_fired = CURRENT_TIMESTAMP WHERE id = ?",
(rule.get("id"),)
)
except Exception:
pass
# Dispatch notifications
actions = rule.get("actions", '["notify_web"]')
if isinstance(actions, str):
try:
actions = json.loads(actions)
except Exception:
actions = ["notify_web"]
self._dispatch_notifications(actions, event_type, severity,
title, details, mac, ip, meta)
break # Only fire once per event type per cycle
except Exception as e:
logger.error("Error firing event %s: %s", event_type, e)
def _store_event(self, event_type, severity, title, details, mac, ip, meta):
try:
self.db.execute(
"""INSERT INTO sentinel_events
(event_type, severity, title, details, mac_address, ip_address, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(event_type, severity, title, details, mac, ip,
json.dumps(meta or {}))
)
except Exception as e:
logger.error("Failed to store sentinel event: %s", e)
def _evaluate_conditions(self, conditions: Dict, logic: str,
mac: str = "", ip: str = "",
meta: Optional[Dict] = None) -> bool:
"""
Evaluate rule conditions with AND/OR logic.
Conditions format: {"mac_contains": "aa:bb", "ip_range": "192.168.1."}
"""
if not conditions:
return True
results = []
meta = meta or {}
for key, value in conditions.items():
if key == "mac_contains":
results.append(value.lower() in mac.lower())
elif key == "mac_not_contains":
results.append(value.lower() not in mac.lower())
elif key == "ip_prefix":
results.append(ip.startswith(value))
elif key == "ip_not_prefix":
results.append(not ip.startswith(value))
elif key == "vendor_contains":
results.append(value.lower() in (meta.get("vendor", "")).lower())
elif key == "min_new_devices":
results.append(int(meta.get("count", 0)) >= int(value))
elif key == "trusted_only":
# Check if MAC is trusted in sentinel_devices
dev = self.db.query_one(
"SELECT trusted FROM sentinel_devices WHERE mac_address = ?", (mac,)
)
is_trusted = bool(dev and dev.get("trusted"))
results.append(is_trusted if value else not is_trusted)
else:
results.append(True) # Unknown condition → pass
if not results:
return True
return all(results) if logic == "AND" else any(results)
def _dispatch_notifications(self, actions: List[str], event_type: str,
severity: str, title: str, details: str,
mac: str, ip: str, meta: Optional[Dict]):
"""Dispatch to registered notifiers."""
payload = {
"event_type": event_type,
"severity": severity,
"title": title,
"details": details,
"mac": mac,
"ip": ip,
"meta": meta or {},
"timestamp": datetime.now().isoformat(),
}
for action in actions:
if action == "notify_web":
# Web notification is automatic via polling — no extra action needed
continue
notifier = self._notifiers.get(action)
if notifier:
try:
notifier.send(payload)
except Exception as e:
logger.error("Notifier %s failed: %s", action, e)
else:
logger.debug("No notifier registered for action: %s", action)
# ── Public query API (for web_utils) ────────────────────────────────
def get_status(self) -> Dict:
unread = 0
total_events = 0
try:
row = self.db.query_one(
"SELECT COUNT(*) AS c FROM sentinel_events WHERE acknowledged = 0"
)
unread = int(row.get("c", 0)) if row else 0
row2 = self.db.query_one("SELECT COUNT(*) AS c FROM sentinel_events")
total_events = int(row2.get("c", 0)) if row2 else 0
except Exception:
pass
return {
"enabled": self.enabled,
"running": self._running,
"initialized": self._initialized,
"known_devices": len(self._known_macs),
"alive_devices": len(self._alive_macs),
"unread_alerts": unread,
"total_events": total_events,
"interval": self.interval,
"check_count": 0,
}
# ── Notification Dispatchers ────────────────────────────────────────────
class DiscordNotifier:
"""Send alerts to a Discord channel via webhook."""
def __init__(self, webhook_url: str):
self.webhook_url = webhook_url
def send(self, payload: Dict):
import urllib.request
severity_colors = {
"info": 0x00FF9A,
"warning": 0xFFD166,
"critical": 0xFF3B3B,
}
color = severity_colors.get(payload.get("severity", "info"), 0x00FF9A)
severity_emoji = {"info": "\u2139\uFE0F", "warning": "\u26A0\uFE0F", "critical": "\uD83D\uDEA8"}
emoji = severity_emoji.get(payload.get("severity", "info"), "\u2139\uFE0F")
embed = {
"title": f"{emoji} {payload.get('title', 'Sentinel Alert')}",
"description": payload.get("details", ""),
"color": color,
"fields": [],
"footer": {"text": f"Bjorn Sentinel \u2022 {payload.get('timestamp', '')}"},
}
if payload.get("mac"):
embed["fields"].append({"name": "MAC", "value": payload["mac"], "inline": True})
if payload.get("ip"):
embed["fields"].append({"name": "IP", "value": payload["ip"], "inline": True})
embed["fields"].append({
"name": "Type", "value": payload.get("event_type", "unknown"), "inline": True
})
body = json.dumps({"embeds": [embed]}).encode("utf-8")
req = urllib.request.Request(
self.webhook_url,
data=body,
headers={"Content-Type": "application/json", "User-Agent": "Bjorn-Sentinel/1.0"},
)
try:
urllib.request.urlopen(req, timeout=10)
except Exception as e:
logger.error("Discord notification failed: %s", e)
class WebhookNotifier:
"""Send alerts to a generic HTTP webhook (POST JSON)."""
def __init__(self, url: str, headers: Optional[Dict] = None):
self.url = url
self.headers = headers or {}
def send(self, payload: Dict):
import urllib.request
body = json.dumps(payload).encode("utf-8")
hdrs = {"Content-Type": "application/json", "User-Agent": "Bjorn-Sentinel/1.0"}
hdrs.update(self.headers)
req = urllib.request.Request(self.url, data=body, headers=hdrs)
try:
urllib.request.urlopen(req, timeout=10)
except Exception as e:
logger.error("Webhook notification failed: %s", e)
class EmailNotifier:
"""Send alerts via SMTP email."""
def __init__(self, smtp_host: str, smtp_port: int, username: str,
password: str, from_addr: str, to_addrs: List[str],
use_tls: bool = True):
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.username = username
self.password = password
self.from_addr = from_addr
self.to_addrs = to_addrs
self.use_tls = use_tls
def send(self, payload: Dict):
import smtplib
from email.mime.text import MIMEText
severity = payload.get("severity", "info").upper()
subject = f"[Bjorn Sentinel][{severity}] {payload.get('title', 'Alert')}"
body = (
f"Event: {payload.get('event_type', 'unknown')}\n"
f"Severity: {severity}\n"
f"Title: {payload.get('title', '')}\n"
f"Details: {payload.get('details', '')}\n"
f"MAC: {payload.get('mac', 'N/A')}\n"
f"IP: {payload.get('ip', 'N/A')}\n"
f"Time: {payload.get('timestamp', '')}\n"
)
msg = MIMEText(body, "plain")
msg["Subject"] = subject
msg["From"] = self.from_addr
msg["To"] = ", ".join(self.to_addrs)
try:
server = smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=15)
if self.use_tls:
server.starttls()
if self.username:
server.login(self.username, self.password)
server.sendmail(self.from_addr, self.to_addrs, msg.as_string())
server.quit()
except Exception as e:
logger.error("Email notification failed: %s", e)