File size: 7,354 Bytes
c4f5819
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
"""FastAPI application: realtime security-log RAG, CSV ingest, mock emitter.

Run (self-host):  uvicorn app.main:app --host 0.0.0.0 --port 8000
Realtime needs a single worker so all WebSocket clients share one process.
"""
from __future__ import annotations

from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any

from fastapi import FastAPI, File, Query, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from . import __version__
from .config import get_settings
from .db import fetch_alerts, fetch_events, fetch_stats, init_db
from .generator import generate, generate_live, to_csv
from .ingest import ingest_rows, parse_csv
from .rag import ask as rag_ask
from .realtime import manager, stream

ROOT = Path(__file__).resolve().parents[1]
WEB_DIR = ROOT / "web"
HTML_DIR = ROOT / "html"

settings = get_settings()


@asynccontextmanager
async def lifespan(app: FastAPI):
    await init_db()
    yield
    await stream.stop()


app = FastAPI(title="ROGLAG Realtime", version=__version__, lifespan=lifespan)
app.add_middleware(
    CORSMiddleware, allow_origins=["*"], allow_methods=["*"],
    allow_headers=["*"], allow_credentials=False,
)


# ---------------------------------------------------------------- broadcasting
async def broadcast_events(events: list[dict[str, Any]], summary: dict[str, Any], kind: str) -> None:
    """Push events (capped) + a summary frame to all WebSocket clients."""
    cap = 300
    to_push = events if len(events) <= cap else (
        [e for e in events if e.get("severity") == "critical"][:cap] or events[:cap]
    )
    for ev in to_push:
        await manager.broadcast({"type": "event", "event": _slim(ev)})
    await manager.broadcast({"type": "summary", "kind": kind, "summary": summary})


def _slim(ev: dict[str, Any]) -> dict[str, Any]:
    return {
        "ts": ev.get("timestamp") or ev.get("ts"),
        "source": ev.get("source"),
        "category": ev.get("category"),
        "label": ev.get("label"),
        "severity": ev.get("severity"),
        "mitre": ev.get("mitre"),
        "risk_score": ev.get("risk_score"),
        "src_ip": ev.get("src_ip"),
        "dst_ip": ev.get("dst_ip"),
        "user": ev.get("user") or ev.get("username"),
        "message": ev.get("message"),
        "reasons": ev.get("reasons"),
        "expected_category": ev.get("expected_category"),
        "matches_label": ev.get("matches_label"),
    }


async def emit_mock(count: int, profile: str) -> dict[str, Any]:
    rows = generate_live(count, profile=profile)
    events, summary = await ingest_rows(rows)
    await broadcast_events(events, summary, kind="mock")
    return summary


# ---------------------------------------------------------------- API: health/stats
@app.get("/health")
async def health() -> dict[str, Any]:
    return {
        "ok": True, "version": __version__,
        "storage": "postgres" if settings.is_postgres else "sqlite",
        "llm_ready": settings.llm_ready,
        "ws_clients": manager.count,
        "stream_running": stream.is_running(),
    }


@app.get("/api/stats")
async def api_stats() -> Any:
    return await fetch_stats()


@app.get("/api/logs")
async def api_logs(limit: int = 100, offset: int = 0, category: str = "",
                   severity: str = "", source: str = "", q: str = "") -> Any:
    return await fetch_events(limit, offset, category, severity, source, q)


@app.get("/api/alerts")
async def api_alerts(limit: int = 20) -> Any:
    return await fetch_alerts(limit)


class AskBody(BaseModel):
    question: str
    limit: int = 8
    use_llm: bool = False
    model: str = ""


@app.post("/api/ask")
async def api_ask(body: AskBody) -> Any:
    if not body.question.strip():
        return JSONResponse({"error": "question is required"}, status_code=400)
    return await rag_ask(body.question.strip(), min(body.limit, 20), body.use_llm, body.model)


# ---------------------------------------------------------------- CSV upload (realtime detect)
@app.post("/api/ingest/csv")
async def api_ingest_csv(file: UploadFile = File(...)) -> Any:
    data = await file.read()
    rows = parse_csv(data)
    if not rows:
        return JSONResponse({"error": "no parseable rows (need a 'message' or 'source' column)"}, status_code=400)
    if len(rows) > settings.max_csv_rows:
        rows = rows[: settings.max_csv_rows]
    events, summary = await ingest_rows(rows)
    summary["filename"] = file.filename
    await broadcast_events(events, summary, kind="csv")
    return summary


# ---------------------------------------------------------------- CSV generator (unique each call)
@app.get("/api/generate/csv")
async def api_generate_csv(count: int = Query(200, ge=1, le=20000),
                           profile: str = Query("mixed")) -> Response:
    records = generate(count, profile=profile, seed=None)  # unseeded -> unique each time
    csv_text = to_csv(records)
    fname = f"roglag_logs_{profile}_{len(records)}.csv"
    return Response(
        content=csv_text, media_type="text/csv",
        headers={"Content-Disposition": f'attachment; filename="{fname}"'},
    )


# ---------------------------------------------------------------- mock emitter (realtime)
@app.post("/api/mock/emit")
async def api_mock_emit(count: int = Query(20, ge=1), profile: str = Query("mixed")) -> Any:
    count = min(count, settings.max_mock_burst)
    return await emit_mock(count, profile)


@app.post("/api/mock/stream/start")
async def api_stream_start(rate: int = Query(3, ge=1, le=50),
                           interval: float = Query(1.0, ge=0.2, le=10.0),
                           profile: str = Query("mixed")) -> Any:
    await stream.start(emit_mock, rate=min(rate, settings.max_mock_burst), interval=interval, profile=profile)
    return {"running": stream.is_running(), "rate": rate, "interval": interval, "profile": profile}


@app.post("/api/mock/stream/stop")
async def api_stream_stop() -> Any:
    await stream.stop()
    return {"running": stream.is_running()}


# ---------------------------------------------------------------- WebSocket
@app.websocket("/ws")
async def ws_feed(ws: WebSocket) -> None:
    await manager.connect(ws)
    try:
        await ws.send_json({"type": "hello", "clients": manager.count})
        while True:
            await ws.receive_text()  # keep-alive; we ignore client messages
    except WebSocketDisconnect:
        await manager.disconnect(ws)
    except Exception:
        await manager.disconnect(ws)


# ---------------------------------------------------------------- static / pages
@app.get("/")
async def index() -> Response:
    live = WEB_DIR / "live.html"
    return FileResponse(live) if live.exists() else JSONResponse({"ok": True, "see": "/docs-html"})


@app.get("/docs-html")
async def docs_html() -> Response:
    return FileResponse(HTML_DIR / "docs.html")


@app.get("/plan")
async def plan_html() -> Response:
    return FileResponse(HTML_DIR / "plan.html")


if WEB_DIR.exists():
    app.mount("/web", StaticFiles(directory=str(WEB_DIR)), name="web")
if HTML_DIR.exists():
    app.mount("/html", StaticFiles(directory=str(HTML_DIR)), name="html")