import json import os import sqlite3 from datetime import datetime, timezone from pathlib import Path from typing import Any import httpx import redis from fastapi import FastAPI, Header, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from app import audit_store from app.collectors.base import run_audit DB_PATH = Path(os.getenv("SQLITE_PATH", "/data/ops.db")) REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090") MAIL_PUBLIC_IP = os.getenv("MAIL_PUBLIC_IP", "") AUDIT_INTERVAL_SEC = int(os.getenv("AUDIT_INTERVAL_SEC", "600")) WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "ligbox-ops-dev-secret") WAZUH_WEBHOOK_SECRET = os.getenv("WAZUH_WEBHOOK_SECRET", "ligbox-wazuh-dev-secret") WAZUH_MIN_TICKET_LEVEL = int(os.getenv("WAZUH_MIN_TICKET_LEVEL", "10")) INTEGRATION_SECRETS = { "onboard": WEBHOOK_SECRET, "wazuh": WAZUH_WEBHOOK_SECRET, } INTEGRATION_SOURCES = { "onboard": "vm112-onboard", "wazuh": "wazuh", } TICKET_EVENTS_BY_SOURCE = { "vm112-onboard": frozenset({"account.created", "onboarding.failed"}), "wazuh": frozenset({"wazuh.alert"}), } TENANT_BY_SOURCE = { "vm112-onboard": 1, "wazuh": 2, } ONBOARD_SOURCE = "vm112-onboard" FUNNEL_EVENT_RANK = { "onboarding.started": 1, "domain.validated": 2, "dns.applied": 3, "account.created": 4, "infra.synced": 5, "onboarding.completed": 6, "company.validated": 7, "webmail.released": 8, "onboarding.failed": 99, } FUNNEL_STAGE_BY_RANK = { 1: "started", 2: "domain_validated", 3: "dns_applied", 4: "account_created", 5: "infra_synced", 6: "completed", 7: "company_validated", 8: "webmail_released", 99: "failed", } FUNNEL_NOTE_EVENTS = frozenset({ "domain.validated", "dns.applied", "infra.synced", "onboarding.completed", "company.validated", "webmail.released", }) app = FastAPI(title="Ligbox Ops Platform API", version="0.5.0-company-gate") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) def db(): DB_PATH.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def init_db(): with db() as conn: conn.executescript(""" CREATE TABLE IF NOT EXISTS tenants ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, ip TEXT NOT NULL, role TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS tickets ( id INTEGER PRIMARY KEY, tenant_id INTEGER, subject TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', payload TEXT, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS webhook_events ( id INTEGER PRIMARY KEY, event_type TEXT NOT NULL, source TEXT NOT NULL, payload TEXT, created_at TEXT NOT NULL); """) now = datetime.now(timezone.utc).isoformat() defaults = [ (1, "VM112 Ligbox Onboard", "10.10.10.112", "onboarding_portal"), (2, "VM104 Wazuh SOC", "10.10.10.104", "security_monitoring"), ] for tid, name, ip, role in defaults: if conn.execute("SELECT COUNT(*) c FROM tenants WHERE id = ?", (tid,)).fetchone()["c"] == 0: conn.execute( "INSERT INTO tenants (id,name,ip,role,created_at) VALUES (?,?,?,?,?)", (tid, name, ip, role, now), ) audit_store.init_audit_schema(conn) conn.commit() def _run_audit_for_domain(tenant_id: int, domain: str) -> dict: now = datetime.now(timezone.utc).isoformat() results = run_audit( tenant_id, domain, vm112_api=VM112_API, mail_public_ip=MAIL_PUBLIC_IP or None, ) with db() as conn: for check_id, item in results.items(): audit_store.upsert_check( conn, tenant_id, domain, check_id, item.get("status", "error"), item.get("message", ""), item.get("evidence"), now, ) conn.commit() return {"tenant_id": tenant_id, "domain": domain, "checks": len(results), "checked_at": now} def _audit_cycle() -> dict: with db() as conn: added = audit_store.sync_domains_from_webhooks(conn) domains = audit_store.list_audit_domains(conn) ran = [] for d in domains: ran.append(_run_audit_for_domain(d["tenant_id"], d["domain"])) return {"domains_synced": added, "audits_run": len(ran), "details": ran} class WebhookPayload(BaseModel): event: str domain: str | None = None session_id: str | None = None data: dict | None = None class TicketStatusUpdate(BaseModel): status: str def _parse_payload(raw: str | None) -> dict: if not raw: return {} try: return json.loads(raw) except json.JSONDecodeError: return {} def _enrich_ticket(row: sqlite3.Row) -> dict: ticket = dict(row) payload = _parse_payload(ticket.get("payload")) data = payload.get("data") or {} ticket["event"] = payload.get("event") ticket["domain"] = payload.get("domain") ticket["session_id"] = payload.get("session_id") ticket["source"] = payload.get("source") or data.get("source") ticket["email"] = data.get("email") ticket["account_verified"] = data.get("account_verified") ticket["needs_review"] = data.get("needs_review") ticket["dns_mode"] = data.get("dns_mode") ticket["severity"] = data.get("level") ticket["rule_id"] = data.get("rule_id") ticket["description"] = data.get("description") ticket["agent"] = data.get("agent") ticket["billing_state"] = payload.get("billing_state") or data.get("billing_state") ticket["webmail_released"] = payload.get("webmail_released") ticket["company_profile"] = payload.get("company_profile") or data.get("company_profile") if not ticket.get("source"): ticket["source"] = "wazuh" if ticket.get("event") == "wazuh.alert" else "vm112-onboard" ticket["payload"] = payload return ticket def _enrich_event(row: sqlite3.Row) -> dict: ev = dict(row) payload = _parse_payload(ev.get("payload")) data = payload.get("data") or {} ev["payload"] = payload ev["domain"] = payload.get("domain") ev["session_id"] = payload.get("session_id") ev["severity"] = data.get("level") return ev def _funnel_stage_for_event(event_type: str) -> str | None: rank = FUNNEL_EVENT_RANK.get(event_type) if rank is None: return None return FUNNEL_STAGE_BY_RANK.get(rank) def _session_timeline(conn, session_id: str) -> list[dict]: sid = (session_id or "").strip() if not sid: return [] rows = conn.execute( """ SELECT id, event_type, source, payload, created_at FROM webhook_events WHERE source = ? ORDER BY id ASC LIMIT 500 """, (ONBOARD_SOURCE,), ).fetchall() timeline = [] for row in rows: payload = _parse_payload(row["payload"]) if (payload.get("session_id") or "").strip() != sid: continue timeline.append({ "id": row["id"], "event_type": row["event_type"], "stage": _funnel_stage_for_event(row["event_type"]), "domain": payload.get("domain"), "data": payload.get("data") or {}, "created_at": row["created_at"], }) return timeline def _find_ticket_id_by_session(conn, session_id: str) -> int | None: sid = (session_id or "").strip() if not sid: return None rows = conn.execute( "SELECT id, payload FROM tickets ORDER BY id DESC LIMIT 300" ).fetchall() for row in rows: payload = _parse_payload(row["payload"]) if (payload.get("session_id") or "").strip() == sid: return int(row["id"]) return None def _find_ticket_id_by_domain(conn, domain: str) -> int | None: dom = (domain or "").strip().lower() if not dom: return None rows = conn.execute( "SELECT id, payload FROM tickets ORDER BY id DESC LIMIT 300" ).fetchall() for row in rows: payload = _parse_payload(row["payload"]) if (payload.get("domain") or "").strip().lower() == dom: return int(row["id"]) return None def _attach_funnel_note( conn, session_id: str, event: str, body: WebhookPayload, now: str, ) -> int | None: tid = _find_ticket_id_by_session(conn, session_id) if not tid and body.domain: tid = _find_ticket_id_by_domain(conn, body.domain) if not tid: return None row = conn.execute("SELECT payload FROM tickets WHERE id = ?", (tid,)).fetchone() payload = _parse_payload(row["payload"]) notes = list(payload.get("funnel_notes") or []) notes.append({"event": event, "at": now, "data": body.data or {}}) payload["funnel_notes"] = notes[-30:] if event == "onboarding.completed": payload["ready_for_ops"] = True if event == "company.validated": payload["billing_state"] = (body.data or {}).get("billing_state") or "awaiting_billing_validation" if body.data and body.data.get("company_profile"): payload["company_profile"] = body.data["company_profile"] if event == "webmail.released": payload["webmail_released"] = True payload["webmail_released_at"] = (body.data or {}).get("webmail_released_at") conn.execute( "UPDATE tickets SET payload = ? WHERE id = ?", (json.dumps(payload), tid), ) return tid def _funnel_summary(conn, window_hours: int = 48) -> dict: from datetime import timedelta cutoff = (datetime.now(timezone.utc) - timedelta(hours=window_hours)).isoformat() rows = conn.execute( """ SELECT event_type, payload, created_at FROM webhook_events WHERE source = ? AND created_at >= ? ORDER BY id ASC """, (ONBOARD_SOURCE, cutoff), ).fetchall() sessions: dict[str, dict] = {} for row in rows: payload = _parse_payload(row["payload"]) sid = (payload.get("session_id") or "").strip() if not sid: continue rank = FUNNEL_EVENT_RANK.get(row["event_type"], 0) sess = sessions.setdefault( sid, { "session_id": sid, "domain": payload.get("domain"), "max_rank": 0, "last_event_at": row["created_at"], "failed": False, }, ) if payload.get("domain"): sess["domain"] = payload.get("domain") if row["created_at"] >= sess["last_event_at"]: sess["last_event_at"] = row["created_at"] if row["event_type"] == "onboarding.failed": sess["failed"] = True sess["max_rank"] = max(sess["max_rank"], 99) elif rank > sess["max_rank"] and not sess["failed"]: sess["max_rank"] = rank stage_counts = {label: 0 for label in FUNNEL_STAGE_BY_RANK.values()} stale_cutoff = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat() active_sessions = [] for sid, sess in sessions.items(): if sess["failed"]: stage = "failed" else: stage = FUNNEL_STAGE_BY_RANK.get(sess["max_rank"], "started") stage_counts[stage] = stage_counts.get(stage, 0) + 1 ticket_id = _find_ticket_id_by_session(conn, sid) stale = sess["last_event_at"] < stale_cutoff and stage not in ("completed", "failed") active_sessions.append({ "session_id": sid, "domain": sess.get("domain"), "current_stage": stage, "last_event_at": sess["last_event_at"], "ticket_id": ticket_id, "stale": stale, }) active_sessions.sort(key=lambda x: x["last_event_at"], reverse=True) return { "window_hours": window_hours, "stages": stage_counts, "active_sessions": active_sessions[:50], "sessions_total": len(sessions), } def _normalize_wazuh_alert(alert: dict[str, Any]) -> WebhookPayload: rule = alert.get("rule") or {} agent = alert.get("agent") or {} data_field = alert.get("data") if isinstance(alert.get("data"), dict) else {} level = rule.get("level", 0) return WebhookPayload( event="wazuh.alert", domain=agent.get("name") or "unknown-agent", session_id=str(alert.get("id") or alert.get("uuid") or ""), data={ "level": level, "rule_id": rule.get("id"), "description": rule.get("description"), "agent": agent.get("name"), "agent_ip": agent.get("ip"), "srcip": data_field.get("srcip"), "source": "wazuh", "raw_rule_groups": rule.get("groups"), }, ) def _ticket_subject(body: WebhookPayload, source_key: str) -> str: if source_key == "wazuh": data = body.data or {} level = data.get("level", "?") agent = data.get("agent") or body.domain or "agent" desc = (data.get("description") or "alerta")[:80] return f"[wazuh L{level}] {agent} — {desc}" if body.event == "company.validated": domain = body.domain or "sem dominio" profile = (body.data or {}).get("company_profile") or {} legal = (profile.get("legal_name") or domain)[:60] return f"[billing-validation] {domain} — {legal}" domain = body.domain or "sem dominio" email = (body.data or {}).get("email") if email: return f"[{body.event}] {domain} — {email}" return f"[{body.event}] {domain}" def _should_create_ticket(source_key: str, body: WebhookPayload) -> bool: if body.event not in TICKET_EVENTS_BY_SOURCE.get(source_key, frozenset()): return False if source_key == "wazuh": level = (body.data or {}).get("level") or 0 return int(level) >= WAZUH_MIN_TICKET_LEVEL return True def _is_duplicate_event( conn, source_key: str, event: str, session_id: str | None, domain: str | None, ) -> bool: sid = (session_id or "").strip() dom = (domain or "").strip().lower() if not sid: return False rows = conn.execute( "SELECT payload FROM webhook_events WHERE event_type = ? AND source = ? ORDER BY id DESC LIMIT 300", (event, source_key), ).fetchall() for row in rows: payload = _parse_payload(row["payload"]) row_sid = (payload.get("session_id") or "").strip() row_dom = (payload.get("domain") or "").strip().lower() if row_sid == sid and (not dom or row_dom == dom): return True return False def _process_ingress(source_key: str, body: WebhookPayload) -> dict: now = datetime.now(timezone.utc).isoformat() stored = body.model_dump() stored["source"] = source_key payload = json.dumps(stored) duplicate = False ticket_created = False ticket_id: int | None = None tenant_id = TENANT_BY_SOURCE.get(source_key, 1) with db() as conn: duplicate = _is_duplicate_event(conn, source_key, body.event, body.session_id, body.domain) if not duplicate: conn.execute( "INSERT INTO webhook_events (event_type,source,payload,created_at) VALUES (?,?,?,?)", (body.event, source_key, payload, now), ) if _should_create_ticket(source_key, body): cur = conn.execute( "INSERT INTO tickets (tenant_id,subject,status,payload,created_at) VALUES (?,?,?,?,?)", (tenant_id, _ticket_subject(body, source_key), "open", payload, now), ) ticket_created = True ticket_id = int(cur.lastrowid) elif ( source_key == ONBOARD_SOURCE and body.event in FUNNEL_NOTE_EVENTS and ((body.session_id or "").strip() or (body.domain or "").strip()) ): ticket_id = _attach_funnel_note(conn, body.session_id or "", body.event, body, now) if not ticket_id and body.event == "company.validated": cur = conn.execute( "INSERT INTO tickets (tenant_id,subject,status,payload,created_at) VALUES (?,?,?,?,?)", (tenant_id, _ticket_subject(body, source_key), "open", payload, now), ) ticket_created = True ticket_id = int(cur.lastrowid) enriched = _parse_payload(payload) enriched["billing_state"] = "awaiting_billing_validation" conn.execute( "UPDATE tickets SET payload = ? WHERE id = ?", (json.dumps(enriched), ticket_id), ) conn.commit() elif source_key == ONBOARD_SOURCE and (body.session_id or "").strip(): ticket_id = _find_ticket_id_by_session(conn, body.session_id or "") if not duplicate: redis.from_url(REDIS_URL).lpush("ops:events", f"{source_key}:{body.event}") return { "accepted": True, "status": "accepted", "event": body.event, "source": source_key, "duplicate": duplicate, "ticket_created": ticket_created, "ticket_id": ticket_id, } def _verify_secret(integration: str, provided: str | None) -> None: expected = INTEGRATION_SECRETS.get(integration) if not expected or provided != expected: raise HTTPException(401, "invalid webhook secret") @app.on_event("startup") def startup(): init_db() try: with db() as conn: audit_store.sync_domains_from_webhooks(conn) except Exception: pass @app.get("/health") def health(): redis.from_url(REDIS_URL).ping() return {"status": "ok", "service": "ligbox-ops-api", "version": "0.5.0-company-gate"} @app.get("/api/v1/integrations") def list_integrations(): return { "integrations": [ {"id": "onboard", "source": "vm112-onboard", "tenant_id": 1, "description": "Portal onboarding VM112"}, {"id": "wazuh", "source": "wazuh", "tenant_id": 2, "description": "Wazuh SOC VM104", "min_ticket_level": WAZUH_MIN_TICKET_LEVEL}, ] } @app.get("/api/v1/tenants") def list_tenants(): with db() as conn: rows = conn.execute("SELECT id,name,ip,role,created_at FROM tenants ORDER BY id").fetchall() return {"tenants": [dict(r) for r in rows]} @app.get("/api/v1/desk/tickets") def list_tickets(status: str | None = Query(default=None), source: str | None = Query(default=None)): with db() as conn: query = "SELECT id,tenant_id,subject,status,payload,created_at FROM tickets" params: list[Any] = [] clauses = [] if status in ("open", "closed"): clauses.append("status = ?") params.append(status) if clauses: query += " WHERE " + " AND ".join(clauses) query += " ORDER BY id DESC LIMIT 100" rows = conn.execute(query, params).fetchall() tickets = [_enrich_ticket(r) for r in rows] if source: tickets = [ t for t in tickets if t.get("source") == source or (t.get("payload") or {}).get("source") == source ] return {"tickets": tickets} @app.get("/api/v1/desk/tickets/{ticket_id}") def get_ticket(ticket_id: int): with db() as conn: row = conn.execute( "SELECT id,tenant_id,subject,status,payload,created_at FROM tickets WHERE id = ?", (ticket_id,), ).fetchone() if not row: raise HTTPException(404, "ticket not found") ticket = _enrich_ticket(row) sid = ticket.get("session_id") if sid: ticket["timeline"] = _session_timeline(conn, sid) ticket["related_events"] = ticket["timeline"][-20:] else: ticket["timeline"] = [] ticket["related_events"] = [] ticket["ready_for_ops"] = (ticket.get("payload") or {}).get("ready_for_ops", False) return ticket @app.patch("/api/v1/desk/tickets/{ticket_id}") def update_ticket(ticket_id: int, body: TicketStatusUpdate): if body.status not in ("open", "closed"): raise HTTPException(400, "status must be open or closed") with db() as conn: cur = conn.execute("UPDATE tickets SET status = ? WHERE id = ?", (body.status, ticket_id)) conn.commit() if cur.rowcount == 0: raise HTTPException(404, "ticket not found") row = conn.execute( "SELECT id,tenant_id,subject,status,payload,created_at FROM tickets WHERE id = ?", (ticket_id,), ).fetchone() return {"ticket": _enrich_ticket(row)} @app.get("/api/v1/desk/summary") def desk_summary(): with db() as conn: open_count = conn.execute("SELECT COUNT(*) c FROM tickets WHERE status = 'open'").fetchone()["c"] closed_count = conn.execute("SELECT COUNT(*) c FROM tickets WHERE status = 'closed'").fetchone()["c"] event_count = conn.execute("SELECT COUNT(*) c FROM webhook_events").fetchone()["c"] wazuh_events = conn.execute("SELECT COUNT(*) c FROM webhook_events WHERE source = 'wazuh'").fetchone()["c"] tenant_count = conn.execute("SELECT COUNT(*) c FROM tenants").fetchone()["c"] recent = conn.execute( "SELECT id,tenant_id,subject,status,payload,created_at FROM tickets ORDER BY id DESC LIMIT 5" ).fetchall() return { "tickets_open": open_count, "tickets_closed": closed_count, "tickets_total": open_count + closed_count, "webhook_events": event_count, "wazuh_events": wazuh_events, "tenants": tenant_count, "recent_tickets": [_enrich_ticket(r) for r in recent], } @app.get("/api/v1/webhooks/events") def list_webhook_events( session_id: str | None = Query(default=None), source: str | None = Query(default=None), ): with db() as conn: if source: rows = conn.execute( "SELECT id,event_type,source,payload,created_at FROM webhook_events WHERE source = ? ORDER BY id DESC LIMIT 100", (source,), ).fetchall() else: rows = conn.execute( "SELECT id,event_type,source,payload,created_at FROM webhook_events ORDER BY id DESC LIMIT 100" ).fetchall() if session_id: sid = session_id.strip() rows = [ r for r in rows if (_parse_payload(r["payload"]).get("session_id") or "").strip() == sid ] return {"events": [_enrich_event(r) for r in rows[:50]]} @app.get("/api/v1/onboard/funnel") def onboard_funnel(window_hours: int = Query(default=48, ge=1, le=168)): with db() as conn: return _funnel_summary(conn, window_hours=window_hours) @app.get("/api/v1/onboard/sessions/{session_id}/timeline") def onboard_session_timeline(session_id: str): sid = session_id.strip() if not sid: raise HTTPException(400, "session_id required") with db() as conn: timeline = _session_timeline(conn, sid) domain = timeline[-1]["domain"] if timeline else None if not domain: for row in timeline: if row.get("domain"): domain = row["domain"] break ticket_id = _find_ticket_id_by_session(conn, sid) return { "session_id": sid, "domain": domain, "ticket_id": ticket_id, "events": timeline, } @app.get("/api/v1/audit/overview") def audit_overview(): with db() as conn: return audit_store.build_overview(conn) @app.get("/api/v1/audit/tenants/{tenant_id}/scorecard") def audit_scorecard(tenant_id: int, domain: str = Query(...)): domain = domain.lower().strip() if not domain: raise HTTPException(400, "domain query param required") with db() as conn: row = conn.execute("SELECT id FROM tenants WHERE id = ?", (tenant_id,)).fetchone() if not row: raise HTTPException(404, "tenant not found") return audit_store.scorecard(conn, tenant_id, domain) @app.post("/api/v1/audit/run/{tenant_id}") def audit_run(tenant_id: int, domain: str = Query(...)): domain = domain.lower().strip() if not domain: raise HTTPException(400, "domain query param required") with db() as conn: row = conn.execute("SELECT id FROM tenants WHERE id = ?", (tenant_id,)).fetchone() if not row: raise HTTPException(404, "tenant not found") conn.execute( """ INSERT OR IGNORE INTO audit_domains (tenant_id, domain, source, created_at) VALUES (?, ?, 'manual', ?) """, (tenant_id, domain, datetime.now(timezone.utc).isoformat()), ) conn.commit() result = _run_audit_for_domain(tenant_id, domain) return {"status": "completed", **result} @app.post("/api/v1/audit/cycle") def audit_cycle(): return _audit_cycle() @app.post("/api/v1/webhooks/ingress/{integration}") async def webhook_ingress( integration: str, request: Request, x_webhook_secret: str | None = Header(default=None), ): if integration not in INTEGRATION_SOURCES: raise HTTPException(404, f"unknown integration: {integration}") _verify_secret(integration, x_webhook_secret) source_key = INTEGRATION_SOURCES[integration] raw = await request.json() if integration == "wazuh" and isinstance(raw, dict) and "rule" in raw: body = _normalize_wazuh_alert(raw) else: body = WebhookPayload.model_validate(raw) return _process_ingress(source_key, body) @app.post("/api/v1/webhooks/onboard") def webhook_onboard(body: WebhookPayload, x_webhook_secret: str | None = Header(default=None)): _verify_secret("onboard", x_webhook_secret) return _process_ingress("vm112-onboard", body) @app.get("/api/v1/infra/vm112/status") def vm112_status(): try: with httpx.Client(timeout=8.0) as c: r = c.get(f"{VM112_API}/api/onboarding/health") return {"vm112": r.json(), "http_status": r.status_code} except Exception as e: return {"vm112": None, "error": str(e)} @app.get("/api/v1/infra/wazuh/status") def wazuh_status(): try: with httpx.Client(timeout=8.0) as c: r = c.get("https://10.10.10.104:55000/", verify=False) return {"wazuh_api": r.json() if r.headers.get("content-type", "").startswith("application/json") else r.text[:200], "http_status": r.status_code} except Exception as e: return {"wazuh_api": None, "error": str(e)}