diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 97d1730..6093c2c 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -445,3 +445,60 @@ def _rec_to_out(r) -> RecommendationOut: # noqa: ANN001 — SQLAlchemy instance created_at=r.created_at.isoformat() if r.created_at else None, updated_at=r.updated_at.isoformat() if r.updated_at else None, ) + + +# ── /api/settings ───────────────────────────────────────────────────────────── + + +class SettingsOut(BaseModel): + webhook_url: str | None + + +class _SettingsUpdate(BaseModel): + webhook_url: str | None = None + + +@router.get("/settings", response_model=SettingsOut) +def get_settings(db: Annotated[Session, Depends(get_db)]) -> SettingsOut: + """Return current application settings.""" + from app.notifications import get_webhook_url + + return SettingsOut(webhook_url=get_webhook_url(db)) + + +@router.patch("/settings", response_model=SettingsOut) +def update_settings( + body: _SettingsUpdate, + db: Annotated[Session, Depends(get_db)], +) -> SettingsOut: + """Persist application settings.""" + from app.notifications import get_webhook_url, set_webhook_url + + if body.webhook_url is not None: + set_webhook_url(db, body.webhook_url.strip() or None) + return SettingsOut(webhook_url=get_webhook_url(db)) + + +class _TestWebhookResponse(BaseModel): + success: bool + message: str + + +@router.post("/settings/webhook/test", response_model=_TestWebhookResponse) +def test_webhook(db: Annotated[Session, Depends(get_db)]) -> _TestWebhookResponse: + """Fire a test notification to the configured webhook URL.""" + from app.notifications import get_webhook_url, send_webhook + + url = get_webhook_url(db) + if not url: + raise HTTPException(status_code=422, detail="No webhook URL configured") + send_webhook( + url, + { + "event": "test", + "title": "NetworkCrawler Test", + "message": "Webhook is working correctly.", + "summary": "Webhook is working correctly.", + }, + ) + return _TestWebhookResponse(success=True, message=f"Test notification sent to {url}") diff --git a/backend/app/db.py b/backend/app/db.py index 47f67d8..1685c74 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -60,6 +60,9 @@ def init_db() -> None: ) from app.models import risk as _risk # noqa: F401 — side-effect import registers ORM tables from app.models import scan as _scan # noqa: F401 — side-effect import registers ORM tables + from app.models import ( + settings as _settings, # noqa: F401 — side-effect import registers ORM tables + ) Base.metadata.create_all(bind=engine) _migrate_schema(engine) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py new file mode 100644 index 0000000..176fa1c --- /dev/null +++ b/backend/app/models/settings.py @@ -0,0 +1,14 @@ +"""AppSetting ORM model — simple key/value store for application configuration.""" + +from sqlalchemy import Column, String, Text + +from app.db import Base + + +class AppSetting(Base): + """A single persisted application setting.""" + + __tablename__ = "app_settings" + + key = Column(String, primary_key=True) + value = Column(Text, nullable=True) diff --git a/backend/app/notifications.py b/backend/app/notifications.py new file mode 100644 index 0000000..de57e4d --- /dev/null +++ b/backend/app/notifications.py @@ -0,0 +1,131 @@ +"""Webhook notification helpers. + +Sends a JSON POST to a configured URL when notable scan events occur: + - One or more new devices appeared on the network + - One or more unacknowledged critical risks were found + +The webhook URL is read from the ``app_settings`` table (key ``webhook_url``), +falling back to the ``NOTIFY_WEBHOOK_URL`` environment variable. If neither +is set, notifications are silently skipped. + +The HTTP request uses only stdlib (``urllib.request``) — no extra runtime dep. +""" + +from __future__ import annotations + +import json +import logging +import os +import urllib.error +import urllib.request +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +_SETTING_KEY = "webhook_url" + + +def get_webhook_url(db: Session) -> str | None: + """Return the configured webhook URL, or None if not set.""" + from sqlalchemy import select + + from app.models.settings import AppSetting + + row = db.execute(select(AppSetting).where(AppSetting.key == _SETTING_KEY)).scalar_one_or_none() + if row and row.value: + return row.value.strip() or None + return os.getenv("NOTIFY_WEBHOOK_URL") or None + + +def set_webhook_url(db: Session, url: str | None) -> None: + """Persist the webhook URL in the settings table.""" + from sqlalchemy.dialects.sqlite import insert as sqlite_insert + + from app.models.settings import AppSetting + + stmt = sqlite_insert(AppSetting).values(key=_SETTING_KEY, value=url or "") + stmt = stmt.on_conflict_do_update(index_elements=["key"], set_={"value": url or ""}) + db.execute(stmt) + db.commit() + + +def send_webhook(url: str, payload: dict) -> None: + """POST ``payload`` as JSON to ``url``. Logs but never raises on failure.""" + try: + data = json.dumps(payload).encode() + req = urllib.request.Request( # noqa: S310 — URL is user-configured + url, + data=data, + headers={"Content-Type": "application/json", "User-Agent": "NetworkCrawler"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 — URL is user-configured + status = resp.status + logger.info("Webhook delivered to %s (HTTP %s)", url, status) + except urllib.error.URLError as exc: + logger.warning("Webhook delivery failed: %s", exc) + except Exception: # noqa: BLE001 — never let notifications crash the scan runner + logger.exception("Unexpected error delivering webhook") + + +def notify_scan_complete( + db: Session, + *, + scan_id: int, + new_device_ids: list[int], + risk_counts: dict[str, int], +) -> None: + """Build a scan-complete notification payload and fire it if a URL is configured. + + Only fires if there is something worth reporting: at least one new device + or at least one unacknowledged critical risk. + """ + url = get_webhook_url(db) + if not url: + return + + critical_count = risk_counts.get("critical", 0) + if not new_device_ids and critical_count == 0: + return # nothing interesting to report + + # Resolve device details for the notification body + from sqlalchemy import select + + from app.models.device import Device + + new_devices = [] + if new_device_ids: + rows = db.execute(select(Device).where(Device.id.in_(new_device_ids))).scalars().all() + new_devices = [ + { + "ip": d.ip_address, + "hostname": d.hostname, + "mac": d.mac_address, + "vendor": d.vendor, + } + for d in rows + ] + + # Build a human-readable summary line (ntfy.sh / plain-text compatible) + parts = [] + if new_devices: + parts.append(f"{len(new_devices)} new device(s)") + if critical_count: + parts.append(f"{critical_count} critical risk(s)") + summary = " and ".join(parts) + " detected" + + payload = { + # Generic webhook fields + "event": "scan_complete", + "scan_id": scan_id, + "summary": summary, + "new_devices": new_devices, + "risk_counts": risk_counts, + # ntfy.sh-compatible fields (title + message) + "title": "NetworkCrawler Alert", + "message": summary, + } + send_webhook(url, payload) diff --git a/backend/app/scan_runner.py b/backend/app/scan_runner.py index 8f59a56..4a3b718 100644 --- a/backend/app/scan_runner.py +++ b/backend/app/scan_runner.py @@ -41,7 +41,7 @@ def run_scan_and_persist(triggered_by: str = "scheduler") -> int: db.commit() result: ScanResult = orchestrate_scan() - _persist_result(db, result) + new_device_ids = _persist_result(db, result) devices_found = len(result.hosts) + len(result.arp_only) scan.current_stage = "analysing" @@ -78,6 +78,18 @@ def run_scan_and_persist(triggered_by: str = "scheduler") -> int: generate_all_recommendations(db) + # Fire webhook notification (new devices or critical risks) + from app.notifications import ( + notify_scan_complete, # noqa: PLC0415 — deferred to avoid circular import at module level + ) + + notify_scan_complete( + db, + scan_id=scan_id, + new_device_ids=new_device_ids, + risk_counts=risk_counts, + ) + scan.status = "completed" scan.finished_at = datetime.now(tz=UTC) scan.duration_seconds = round(time.monotonic() - t0, 2) @@ -105,12 +117,20 @@ def run_scan_and_persist(triggered_by: str = "scheduler") -> int: return scan_id -def _persist_result(db: Session, result: ScanResult) -> None: - """Upsert all devices and ports from a ScanResult into the database.""" +def _persist_result(db: Session, result: ScanResult) -> list[int]: + """Upsert all devices and ports from a ScanResult into the database. + + Returns a list of device IDs that were newly created in this scan. + """ from sqlalchemy import select + from app.models.device import Device as DeviceModel from app.models.device import Port + # Snapshot existing IPs before upserting so we can detect brand-new devices + existing_ips: set[str] = {row[0] for row in db.execute(select(DeviceModel.ip_address)).all()} + new_device_ids: list[int] = [] + # Full nmap results for nh in result.hosts: device = upsert_device( @@ -121,6 +141,8 @@ def _persist_result(db: Session, result: ScanResult) -> None: os_guess=nh.os_guess or None, ) db.flush() + if nh.ip not in existing_ips: + new_device_ids.append(device.id) current_ports = {(p.port_number, p.protocol) for p in nh.ports} for port in nh.ports: @@ -141,11 +163,15 @@ def _persist_result(db: Session, result: ScanResult) -> None: # ARP-only hosts (no nmap data) for ah in result.arp_only: - upsert_device( + device = upsert_device( db, ip_address=ah.ip, mac_address=ah.mac or None, vendor=ah.vendor or None, ) + db.flush() + if ah.ip not in existing_ips: + new_device_ids.append(device.id) db.commit() + return new_device_ids diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py new file mode 100644 index 0000000..e382338 --- /dev/null +++ b/backend/tests/test_notifications.py @@ -0,0 +1,129 @@ +"""Unit tests for app.notifications.""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +from app.notifications import notify_scan_complete, send_webhook + +# ── send_webhook ───────────────────────────────────────────────────────────── + + +def _make_response(status: int = 200) -> MagicMock: + resp = MagicMock() + resp.status = status + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + +def test_send_webhook_success(): + """send_webhook posts JSON and does not raise on success.""" + with patch("urllib.request.urlopen", return_value=_make_response(200)) as mock_open: + send_webhook("https://example.com/hook", {"event": "test"}) + mock_open.assert_called_once() + req = mock_open.call_args[0][0] + assert req.full_url == "https://example.com/hook" + assert req.get_header("Content-type") == "application/json" + assert json.loads(req.data) == {"event": "test"} + + +def test_send_webhook_url_error_does_not_raise(): + """send_webhook swallows URLError and does not propagate.""" + import urllib.error + + with patch( + "urllib.request.urlopen", + side_effect=urllib.error.URLError("timeout"), + ): + send_webhook("https://example.com/hook", {"event": "test"}) # must not raise + + +def test_send_webhook_unexpected_error_does_not_raise(): + """send_webhook swallows unexpected exceptions.""" + with patch("urllib.request.urlopen", side_effect=RuntimeError("boom")): + send_webhook("https://example.com/hook", {"event": "test"}) # must not raise + + +# ── notify_scan_complete ────────────────────────────────────────────────────── + + +def _make_db(webhook_url: str | None = "https://example.com/hook") -> MagicMock: + """Return a mock DB session that satisfies get_webhook_url().""" + db = MagicMock() + setting_row = MagicMock() + setting_row.value = webhook_url + result = MagicMock() + result.scalar_one_or_none.return_value = setting_row if webhook_url else None + db.execute.return_value = result + return db + + +def test_notify_scan_complete_no_url_skips(): + """Skips notification when no webhook URL is configured.""" + db = _make_db(webhook_url=None) + with patch("app.notifications.send_webhook") as mock_send: + notify_scan_complete(db, scan_id=1, new_device_ids=[], risk_counts={"critical": 1}) + mock_send.assert_not_called() + + +def test_notify_scan_complete_nothing_to_report_skips(): + """Skips notification when no new devices and no critical risks.""" + db = _make_db() + with patch("app.notifications.send_webhook") as mock_send: + notify_scan_complete( + db, scan_id=1, new_device_ids=[], risk_counts={"high": 2, "critical": 0} + ) + mock_send.assert_not_called() + + +def test_notify_scan_complete_fires_for_new_devices(): + """Fires when new devices are present.""" + db = _make_db() + # Mock Device query + dev = MagicMock() + dev.ip_address = "192.168.1.50" + dev.hostname = "newbox" + dev.mac_address = "aa:bb:cc:dd:ee:ff" + dev.vendor = "Acme" + scalars_result = MagicMock() + scalars_result.all.return_value = [dev] + execute_result_for_devices = MagicMock() + execute_result_for_devices.scalars.return_value = scalars_result + + setting_row = MagicMock() + setting_row.value = "https://example.com/hook" + setting_result = MagicMock() + setting_result.scalar_one_or_none.return_value = setting_row + + db.execute.side_effect = [setting_result, execute_result_for_devices] + + with patch("app.notifications.send_webhook") as mock_send: + notify_scan_complete(db, scan_id=5, new_device_ids=[42], risk_counts={"critical": 0}) + + mock_send.assert_called_once() + payload = mock_send.call_args[0][1] + assert payload["event"] == "scan_complete" + assert len(payload["new_devices"]) == 1 + assert payload["new_devices"][0]["ip"] == "192.168.1.50" + + +def test_notify_scan_complete_fires_for_critical_risks(): + """Fires when critical risks are present (no new devices needed).""" + db = _make_db() + setting_row = MagicMock() + setting_row.value = "https://example.com/hook" + setting_result = MagicMock() + setting_result.scalar_one_or_none.return_value = setting_row + db.execute.return_value = setting_result + + with patch("app.notifications.send_webhook") as mock_send: + notify_scan_complete( + db, scan_id=7, new_device_ids=[], risk_counts={"critical": 3, "high": 1} + ) + + mock_send.assert_called_once() + payload = mock_send.call_args[0][1] + assert payload["risk_counts"]["critical"] == 3 + assert "critical risk" in payload["summary"] diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 4cd5c7a..8b672d7 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -2,7 +2,7 @@ * SettingsPage — application configuration and system information. * Route: /settings */ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Card, PageHeader } from "../components"; import { useAppVersion } from "../hooks/useAppVersion"; @@ -18,9 +18,82 @@ function copyViaExecCommand(text: string): void { document.body.removeChild(el); } +function useWebhookSettings() { + const [webhookUrl, setWebhookUrl] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [feedback, setFeedback] = useState<{ + type: "success" | "error"; + msg: string; + } | null>(null); + + useEffect(() => { + fetch("/api/settings") + .then((r) => r.json()) + .then((d: { webhook_url: string | null }) => + setWebhookUrl(d.webhook_url ?? ""), + ) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const flash = (type: "success" | "error", msg: string) => { + setFeedback({ type, msg }); + setTimeout(() => setFeedback(null), 3500); + }; + + const save = () => { + setSaving(true); + fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ webhook_url: webhookUrl.trim() || null }), + }) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + flash("success", "Saved"); + }) + .catch(() => flash("error", "Failed to save")) + .finally(() => setSaving(false)); + }; + + const test = () => { + setTesting(true); + fetch("/api/settings/webhook/test", { method: "POST" }) + .then((r) => r.json()) + .then((d: { success: boolean; message: string }) => + flash(d.success ? "success" : "error", d.message), + ) + .catch(() => flash("error", "Test request failed")) + .finally(() => setTesting(false)); + }; + + return { + webhookUrl, + setWebhookUrl, + loading, + saving, + testing, + feedback, + save, + test, + }; +} + export function SettingsPage() { - const { version, loading } = useAppVersion(); + const { version, loading: vLoading } = useAppVersion(); const [copied, setCopied] = useState(false); + const { + webhookUrl, + setWebhookUrl, + loading: whLoading, + saving, + testing, + feedback, + save, + test, + } = useWebhookSettings(); const handleCopyVersion = () => { if (!version) return; @@ -47,6 +120,100 @@ export function SettingsPage() {
+ {/* ── Notifications ───────────────────────────────────────────────── */} +
+

+ Notifications +

+ +

+ Send a webhook when a new device is detected or critical risks are + found. Compatible with{" "} + + ntfy.sh + + , Gotify, Home Assistant, Slack, and Discord. +

+ + +
+ setWebhookUrl(e.target.value)} + placeholder="https://ntfy.sh/my-topic" + disabled={whLoading} + className="flex-1 rounded-md border border-[var(--color-border)] bg-[var(--color-background)] px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-secondary)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-primary)] disabled:opacity-50" + /> + + +
+ + {feedback && ( +

+ {feedback.msg} +

+ )} + +

+ The webhook fires at the end of each scan if new devices appear or + critical risks are detected. A JSON payload is posted with{" "} + + event + + ,{" "} + + new_devices + + ,{" "} + + risk_counts + + , and ntfy.sh-compatible{" "} + + title + {" "} + /{" "} + + message + {" "} + fields. +

+
+
+ {/* ── System ─────────────────────────────────────────────────────── */}

- {loading ? ( + {vLoading ? ( ) : version ? (