diff --git a/README.md b/README.md index 1cf98e1..3859311 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,11 @@ open and unacknowledged: ![Escalation policy ladder](docs/assets/escalation.png) +**Network discovery** — scan a CIDR on a schedule; responsive addresses become hosts +(with a template applied): + +![Discovery rules](docs/assets/discovery.png) + **Host detail** — items with min/max and server-rendered history sparklines: | Dark | Light | diff --git a/app/api/routes/web/__init__.py b/app/api/routes/web/__init__.py index b80f933..56203ad 100644 --- a/app/api/routes/web/__init__.py +++ b/app/api/routes/web/__init__.py @@ -3,6 +3,7 @@ from app.api.routes.web.auth import router as auth_router from app.api.routes.web.channels import router as channels_router from app.api.routes.web.dashboard import router as dashboard_router +from app.api.routes.web.discovery import router as discovery_router from app.api.routes.web.escalation import router as escalation_router from app.api.routes.web.hosts import router as hosts_router from app.api.routes.web.maintenances import router as maintenances_router @@ -17,6 +18,7 @@ router.include_router(channels_router) router.include_router(escalation_router) router.include_router(maintenances_router) +router.include_router(discovery_router) router.include_router(problems_router) __all__ = ["router"] diff --git a/app/api/routes/web/discovery.py b/app/api/routes/web/discovery.py new file mode 100644 index 0000000..e081959 --- /dev/null +++ b/app/api/routes/web/discovery.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +from fastapi import APIRouter, Form, HTTPException, Request, status +from fastapi.responses import HTMLResponse, RedirectResponse + +from app.api.deps.db import DBSession +from app.api.routes.web._shared import login_redirect, resolve_current_user, templates +from app.core.models.discovery import DiscoveryMethod +from app.core.models.user import User +from app.core.schemas.discovery import DiscoveryRuleCreate +from app.core.services.discovery_service import DiscoveryService +from app.core.services.template_service import TemplateService + +router = APIRouter() + + +async def _context( + session: DBSession, user: User, error: str | None = None, flash: str | None = None +) -> dict[str, object]: + tmpls = list(await TemplateService(session).list_for_owner(user.id)) + return { + "current_user": user, + "active_nav": "discovery", + "rules": list(await DiscoveryService(session).list_for_owner(user.id)), + "templates_list": tmpls, + "template_names": {t.id: t.name for t in tmpls}, + "methods": [m.value for m in DiscoveryMethod], + "error": error, + "flash": flash, + } + + +@router.get("/discovery", response_class=HTMLResponse, response_model=None) +async def discovery_page(request: Request, session: DBSession) -> HTMLResponse | RedirectResponse: + user = await resolve_current_user(request, session) + if user is None: + return login_redirect() + context = await _context(session, user, flash=request.query_params.get("flash")) + return templates.TemplateResponse(request, "discovery/list.html", context=context) + + +@router.post("/discovery/new", response_class=HTMLResponse, response_model=None) +async def create_discovery_form( + request: Request, + session: DBSession, + name: str = Form(...), + cidr: str = Form(...), + method: str = Form("ping"), + port: str | None = Form(None), + template_id: str | None = Form(None), + interval_seconds: int = Form(3600), + is_enabled: str | None = Form(None), +) -> HTMLResponse | RedirectResponse: + user = await resolve_current_user(request, session) + if user is None: + return login_redirect() + service = DiscoveryService(session) + + async def _error(message: str) -> HTMLResponse: + return templates.TemplateResponse( + request, + "discovery/list.html", + context=await _context(session, user, error=message), + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + + template_uuid = uuid.UUID(template_id) if template_id else None + if ( + template_uuid is not None + and await TemplateService(session).get(template_uuid, user.id) is None + ): + return await _error("Unknown template.") + try: + payload = DiscoveryRuleCreate( + name=name, + cidr=cidr, + method=DiscoveryMethod(method), + port=int(port) if port else None, + template_id=template_uuid, + interval_seconds=interval_seconds, + is_enabled=is_enabled == "on", + ) + except (ValueError, TypeError) as exc: + return await _error(str(exc)) + + await service.create(user.id, payload) + return RedirectResponse(url="/discovery?flash=Saved", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post("/discovery/{rule_id}/scan") +async def scan_discovery_form( + rule_id: uuid.UUID, request: Request, session: DBSession +) -> RedirectResponse: + user = await resolve_current_user(request, session) + if user is None: + return login_redirect() + service = DiscoveryService(session) + rule = await service.get(rule_id, user.id) + if rule is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found") + created = await service.scan_rule(rule, datetime.now(UTC)) + return RedirectResponse( + url=f"/discovery?flash=Discovered+{created}+host(s)", status_code=status.HTTP_303_SEE_OTHER + ) + + +@router.post("/discovery/{rule_id}/delete") +async def delete_discovery_form( + rule_id: uuid.UUID, request: Request, session: DBSession +) -> RedirectResponse: + user = await resolve_current_user(request, session) + if user is None: + return login_redirect() + service = DiscoveryService(session) + rule = await service.get(rule_id, user.id) + if rule is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found") + await service.delete(rule) + return RedirectResponse(url="/discovery?flash=Deleted", status_code=status.HTTP_303_SEE_OTHER) diff --git a/docs/assets/discovery.png b/docs/assets/discovery.png new file mode 100644 index 0000000..5235ab7 Binary files /dev/null and b/docs/assets/discovery.png differ diff --git a/docs/roadmap.md b/docs/roadmap.md index bf903a8..7b4ea58 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -130,7 +130,8 @@ GhostMonitor's reason to exist over a plain Zabbix clone is privacy (ghost-suite template + interval) is scanned on a schedule; responsive addresses that are not yet a host are provisioned as hosts, with the rule's template items applied. Guardrails: CIDR capped (≤ 1024 hosts), bounded scan concurrency, dedupe by address, owner-scoped. - REST CRUD + scan-now under `/api/discovery-rules`. *(next)* a web UI. + REST CRUD + scan-now under `/api/discovery-rules`, and a `/discovery` web page to + manage rules (create, scan-now, delete). - *(deferred — measure first)* Distributed collection (proxy/agent fan-in) and history storage scaling (TimescaleDB / a dedicated TSDB). Deliberately not built yet: it is premature without real load data. Add ingestion-volume/throughput metrics first so diff --git a/templates/base.html b/templates/base.html index 95eb6b8..84c8cba 100644 --- a/templates/base.html +++ b/templates/base.html @@ -29,6 +29,7 @@ Dashboard Hosts Problems + Discovery Channels Escalation Maintenances diff --git a/templates/discovery/list.html b/templates/discovery/list.html new file mode 100644 index 0000000..1b44c54 --- /dev/null +++ b/templates/discovery/list.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} + +{% block title %}Discovery · GhostMonitor{% endblock %} + +{% block page %} +
+
+
+

+ ~ $ discovery +

+
+ Scan a network range on a schedule; responsive addresses become hosts (with a template applied). +
+
+ + {% if error %}
{{ error }}
{% endif %} + {% if flash %}
{{ flash }}
{% endif %} + +
+

Rules {{ rules|length }}

+ {% if rules %} + + + + + + {% for r in rules %} + + + + + + + + + + + {% endfor %} + +
NameRangeMethodTemplateEveryLast runEnabled
{{ r.name }}{{ r.cidr }}{{ r.method.value }}{% if r.method.value == 'tcp' and r.port %}:{{ r.port }}{% endif %}{{ template_names.get(r.template_id, '—') }}{{ (r.interval_seconds // 60) }}m{{ r.last_run_at.strftime('%Y-%m-%d %H:%M') if r.last_run_at else 'never' }}{{ 'yes' if r.is_enabled else 'no' }} +
+ +
+
+ +
+
+ {% else %} +

No discovery rules yet.

+ {% endif %} +
+ +
+

New rule

+
+
+ + +
+
+ + + +
+
+ + +
+
+ +
+
+
+
+
+{% endblock %} diff --git a/tests/test_discovery.py b/tests/test_discovery.py index eed52b3..2a19df2 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -104,3 +104,47 @@ async def fake_reachable(address: str, method: Any, port: Any) -> bool: deleted = await client.delete(f"/api/discovery-rules/{rule_id}", headers=auth_headers) assert deleted.status_code == 204 + + +async def test_discovery_web_create_scan_delete( + web_client: httpx.AsyncClient, session: Any, user: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + created = await web_client.post( + "/discovery/new", + data={ + "name": "lan", + "cidr": "10.9.9.0/30", + "method": "ping", + "interval_seconds": "3600", + "is_enabled": "on", + }, + ) + assert created.status_code in (200, 303) + rules = list(await DiscoveryService(session).list_for_owner(user.id)) + assert [r.name for r in rules] == ["lan"] + + page = await web_client.get("/discovery") + assert "lan" in page.text and "10.9.9.0/30" in page.text + + async def fake_reachable(address: str, method: Any, port: Any) -> bool: + return True + + monkeypatch.setattr(ds, "check_reachable", fake_reachable) + scan = await web_client.post(f"/discovery/{rules[0].id}/scan") + assert scan.status_code in (200, 303) + + hosts = list(await HostService(session).list_for_owner(user.id)) + assert {"10.9.9.1", "10.9.9.2"} <= {h.address for h in hosts} + + deleted = await web_client.post(f"/discovery/{rules[0].id}/delete") + assert deleted.status_code in (200, 303) + assert list(await DiscoveryService(session).list_for_owner(user.id)) == [] + + +async def test_discovery_web_rejects_huge_cidr(web_client: httpx.AsyncClient) -> None: + resp = await web_client.post( + "/discovery/new", + data={"name": "huge", "cidr": "10.0.0.0/8", "method": "ping", "interval_seconds": "3600"}, + ) + assert resp.status_code == 422 + assert "max is" in resp.text