Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
3 changes: 3 additions & 0 deletions backend/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions backend/app/models/settings.py
Original file line number Diff line number Diff line change
@@ -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)
131 changes: 131 additions & 0 deletions backend/app/notifications.py
Original file line number Diff line number Diff line change
@@ -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)
34 changes: 30 additions & 4 deletions backend/app/scan_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand All @@ -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
Loading
Loading