Add read-only web dashboard and phone push notifications
Both zero-dependency (stdlib http.server + urllib), consistent with the project's no-dependency-tree stance for a security daemon. Web dashboard (enodia_sentinel/web.py + static/dashboard.html): - read-only JSON API over the log dir: /api/status, /api/alerts, /api/alerts/<id>, /api/events - bearer-token auth (constant-time; header or ?token=), required on non- loopback binds, auto-generated + persisted (0600) when unset - binds the host's Tailscale IP by default (auto-detected), reachable from the tailnet but not the LAN/internet - self-contained dark SPA: severity cards, live alert list, full snapshot viewer; 10s auto-refresh - path-traversal-safe alert lookup; `enodia-sentinel web` subcommand; daemon now writes a pidfile so the dashboard can show live status - hardened enodia-sentinel-web.service (read-only, no caps) Phone push (enodia_sentinel/notify/): - pluggable backends — ntfy, Pushover, generic webhook — each separating a pure build() (unit-tested, no network) from send() - a backend turns on when its config keys are set; pushes gated by notify_min_severity; severity → per-service priority/tags - fired from snapshot.capture on worker threads, errors swallowed - desktop notify-send retained Tests: +16 (9 web incl. a real-server 401/200 auth test, 7 notify request-build cases). 55/55 pass. Live end-to-end verified: daemon → alert → API → page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0eb5077551
commit
c00fff224c
17 changed files with 960 additions and 5 deletions
85
tests/test_notify.py
Normal file
85
tests/test_notify.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Tests for notification request construction (no network)."""
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from enodia_sentinel.alert import Alert, Severity
|
||||
from enodia_sentinel.config import Config
|
||||
from enodia_sentinel.notify import Notification, backends, enabled_backends, min_severity
|
||||
|
||||
|
||||
def notif(sev=Severity.CRITICAL):
|
||||
alerts = [Alert(sev, "reverse_shell", "rsh:1", "detail", (1,), 100010,
|
||||
"c2-reverse-shell")]
|
||||
return Notification.from_alerts(alerts, host="woofbox",
|
||||
snapshot_name="alert-x.log",
|
||||
dashboard_url="https://sentinel.example")
|
||||
|
||||
|
||||
class TestNotification(unittest.TestCase):
|
||||
def test_summary_fields(self):
|
||||
n = notif()
|
||||
self.assertEqual(n.severity, Severity.CRITICAL)
|
||||
self.assertIn("reverse_shell", n.signatures)
|
||||
self.assertIn(100010, n.sids)
|
||||
self.assertIn("woofbox", n.title())
|
||||
self.assertIn("alert-x.log", n.message())
|
||||
self.assertIn("sentinel.example", n.message())
|
||||
|
||||
|
||||
class TestEnable(unittest.TestCase):
|
||||
def test_backends_off_by_default(self):
|
||||
self.assertEqual(enabled_backends(Config()), [])
|
||||
|
||||
def test_ntfy_enabled_when_configured(self):
|
||||
c = Config()
|
||||
c.notify_ntfy_url = "https://ntfy.sh"
|
||||
c.notify_ntfy_topic = "enodia-secret"
|
||||
self.assertIn(backends.Ntfy, enabled_backends(c))
|
||||
|
||||
def test_min_severity(self):
|
||||
c = Config()
|
||||
c.notify_min_severity = "CRITICAL"
|
||||
self.assertEqual(min_severity(c), Severity.CRITICAL)
|
||||
|
||||
|
||||
class TestNtfy(unittest.TestCase):
|
||||
def test_build(self):
|
||||
c = Config()
|
||||
c.notify_ntfy_url = "https://ntfy.sh/"
|
||||
c.notify_ntfy_topic = "enodia-secret"
|
||||
c.notify_ntfy_token = "tok_123"
|
||||
req = backends.Ntfy.build(c, notif(Severity.CRITICAL))
|
||||
self.assertEqual(req.full_url, "https://ntfy.sh/enodia-secret")
|
||||
self.assertEqual(req.get_method(), "POST")
|
||||
self.assertEqual(req.headers["Priority"], "5")
|
||||
self.assertEqual(req.headers["Authorization"], "Bearer tok_123")
|
||||
self.assertIn(b"reverse_shell", req.data)
|
||||
|
||||
|
||||
class TestPushover(unittest.TestCase):
|
||||
def test_build(self):
|
||||
c = Config()
|
||||
c.notify_pushover_token = "app"
|
||||
c.notify_pushover_user = "usr"
|
||||
req = backends.Pushover.build(c, notif(Severity.HIGH))
|
||||
self.assertIn("api.pushover.net", req.full_url)
|
||||
body = req.data.decode()
|
||||
self.assertIn("token=app", body)
|
||||
self.assertIn("user=usr", body)
|
||||
self.assertIn("priority=0", body)
|
||||
|
||||
|
||||
class TestWebhook(unittest.TestCase):
|
||||
def test_build_json(self):
|
||||
c = Config()
|
||||
c.notify_webhook_url = "https://hook.example/x"
|
||||
req = backends.Webhook.build(c, notif())
|
||||
payload = json.loads(req.data)
|
||||
self.assertEqual(payload["host"], "woofbox")
|
||||
self.assertEqual(payload["severity"], "CRITICAL")
|
||||
self.assertEqual(req.headers["Content-type"], "application/json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue