80320ae698
Close audit findings: anti-spoof client IP for login rate limit, JWT/CORS enforce on startup, SSH host-key verification and sudo via stdin, optional WinRM HTTPS, SSE httpOnly cookie auth, ingest body limit, ILIKE escaping, and HMAC API key hashing with legacy SHA-256 fallback.
89 lines
2.8 KiB
Python
89 lines
2.8 KiB
Python
import asyncio
|
|
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from fastapi import APIRouter, Cookie, Depends, HTTPException
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.auth.jwt_auth import verify_access_token
|
|
from app.auth.stream_cookie import STREAM_COOKIE_NAME
|
|
from app.database import SessionLocal, get_db
|
|
from app.models import Event, Problem
|
|
from app.config import get_settings
|
|
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
|
|
from app.version import APP_VERSION
|
|
|
|
router = APIRouter(prefix="/stream", tags=["stream"])
|
|
|
|
SSE_INTERVAL_SEC = 5
|
|
|
|
|
|
def _dashboard_snapshot(db: Session) -> dict:
|
|
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
|
events_24h = db.scalar(
|
|
select(func.count()).select_from(Event).where(Event.received_at >= since)
|
|
) or 0
|
|
problems_open = (
|
|
db.scalar(select(func.count()).select_from(Problem).where(Problem.status == "open")) or 0
|
|
)
|
|
last_event = db.scalar(select(Event.id).order_by(Event.received_at.desc()).limit(1))
|
|
settings = get_settings()
|
|
return {
|
|
"type": "dashboard",
|
|
"version": APP_VERSION,
|
|
"events_last_24h": events_24h,
|
|
"hosts_stale": count_stale_hosts(db, settings.sac_heartbeat_stale_minutes),
|
|
"heartbeats_24h": db.scalar(
|
|
select(func.count())
|
|
.select_from(Event)
|
|
.where(Event.received_at >= since, Event.type == HEARTBEAT_TYPE)
|
|
)
|
|
or 0,
|
|
"daily_reports_24h": db.scalar(
|
|
select(func.count())
|
|
.select_from(Event)
|
|
.where(Event.received_at >= since, Event.type.in_(DAILY_REPORT_TYPES))
|
|
)
|
|
or 0,
|
|
"problems_open": problems_open,
|
|
"last_event_id": last_event,
|
|
"at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
|
|
|
|
async def _sse_generator():
|
|
try:
|
|
while True:
|
|
db = SessionLocal()
|
|
try:
|
|
payload = _dashboard_snapshot(db)
|
|
finally:
|
|
db.close()
|
|
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
|
await asyncio.sleep(SSE_INTERVAL_SEC)
|
|
except asyncio.CancelledError:
|
|
return
|
|
|
|
|
|
def _sse_auth(
|
|
sac_stream: str | None = Cookie(None, alias=STREAM_COOKIE_NAME),
|
|
db: Session = Depends(get_db),
|
|
) -> str:
|
|
token = (sac_stream or "").strip()
|
|
if not token:
|
|
raise HTTPException(status_code=401, detail="SSE authentication required")
|
|
return verify_access_token(token, db=db)
|
|
|
|
|
|
@router.get("/events")
|
|
async def stream_events(
|
|
_user: str = Depends(_sse_auth),
|
|
) -> StreamingResponse:
|
|
return StreamingResponse(
|
|
_sse_generator(),
|
|
media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
)
|