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 Depends, FastAPI, Header, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from app import audit_store, auth, assist_store, push_service from app.auth_routes import router as auth_router from app.registration_routes import router as registration_router from app.mfa_recovery_routes import router as mfa_recovery_router from app.assist_routes import router as assist_router, process_escalation_webhook from app.crm_routes import router as crm_router from app import crm_leads, integration_health from app.cloudflare_dns import fetch_domain_dns from app.modules.routes import router as modules_router from app.vm112_domains_routes import router as vm112_domains_router from app.purge_auth_routes import router as purge_auth_router from app.carbonio_release_routes import router as carbonio_release_router from app.migration.router import router as migration_router from app.billing_routes import router as billing_router from app.security_routes import router as security_router from app.infra_stack_routes import router as infra_stack_router from app.vm123.routes import router as vm123_router from app.agents.routes import router as agents_router from app.agents.store import init_agent_schema from app.collectors.base import run_audit from app.permissions import ( can_assign_ticket, can_list_webhook_events, can_patch_ticket, can_read_audit_overview, can_read_audit_scorecard, can_read_cloudflare_dns, can_read_funnel, can_read_session_timeline, can_read_tickets, can_run_audit, should_mask_sensitive, ) 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, "security": WEBHOOK_SECRET, "wazuh": WAZUH_WEBHOOK_SECRET, } INTEGRATION_SOURCES = { "onboard": "vm112-onboard", "security": "vm112-security", "wazuh": "wazuh", } TICKET_EVENTS_BY_SOURCE = { # Ticket no início do onboarding (email+senha / criar servidor) — Roger 2026-06-10 "vm112-onboard": frozenset({"onboarding.started", "onboarding.failed"}), "wazuh": frozenset({"wazuh.alert"}), } TENANT_BY_SOURCE = { "vm112-onboard": 1, "wazuh": 2, } ONBOARD_SOURCE = "vm112-onboard" FUNNEL_EVENT_RANK = { "session.started": 0, "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({ "account.created", "domain.validated", "dns.applied", "infra.synced", "onboarding.completed", "company.validated", "webmail.released", }) ASSIST_ESCALATION_EVENTS = frozenset({"onboarding.escalated", "onboarding.failed"}) ASSIST_LIFECYCLE_EVENTS = frozenset({"onboarding.assist.started", "onboarding.assist.ended"}) TICKET_ACTIVE_STATUSES = frozenset({"open", "escalated", "assisting", "resolved"}) app = FastAPI(title="Ligbox Ops Platform API", version="0.9.7-spec029-agentic") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) app.include_router(auth_router) app.include_router(registration_router) app.include_router(mfa_recovery_router) app.include_router(assist_router) app.include_router(crm_router) app.include_router(modules_router) app.include_router(vm112_domains_router) app.include_router(purge_auth_router) app.include_router(security_router) app.include_router(carbonio_release_router) app.include_router(migration_router) app.include_router(billing_router) app.include_router(infra_stack_router) app.include_router(vm123_router) app.include_router(agents_router) TICKET_COLUMNS = "id,tenant_id,subject,status,payload,created_at,assigned_to,assigned_at,session_id,assist_mode,assisted_by,assisted_at,client_paused" def db(): DB_PATH.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(DB_PATH, timeout=30.0) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=30000") 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) auth.init_auth_schema(conn) assist_store.init_assist_schema(conn) from app import carbonio_release_store carbonio_release_store.init_schema(conn) from app.migration import store as migration_store from app import billing_store migration_store.init_schema(conn) billing_store.init_schema(conn) from app.vm112_purge_jobs import init_purge_jobs_schema from app.purge_auth_codes import init_purge_auth_schema init_purge_jobs_schema(conn) init_purge_auth_schema(conn) init_agent_schema(conn) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=30000") 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 | None = None assigned_to: str | None = None 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") ticket["activation_url"] = data.get("activation_url") ticket["desk_message"] = data.get("message") ticket["registration_role"] = data.get("role") ticket["assist_mode"] = ticket.get("assist_mode") ticket["assisted_by"] = ticket.get("assisted_by") ticket["assisted_at"] = ticket.get("assisted_at") ticket["client_paused"] = bool(ticket.get("client_paused")) ticket["crm_track"] = payload.get("crm_track") ticket["lead_detected_at"] = payload.get("lead_detected_at") ticket["lead_funnel_stage"] = payload.get("lead_funnel_stage") ticket["account_email"] = payload.get("account_email") or data.get("email") if not ticket.get("source"): ticket["source"] = "wazuh" if ticket.get("event") == "wazuh.alert" else "vm112-onboard" ticket["payload"] = payload return ticket def _visible_ticket(ticket: dict, user: auth.DeskUser) -> dict: if should_mask_sensitive(user.role): return auth.mask_ticket(ticket) 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 row = conn.execute( "SELECT id FROM tickets WHERE session_id = ? ORDER BY id DESC LIMIT 1", (sid,), ).fetchone() if row: return int(row["id"]) 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 FUNNEL_BACKFILL_EVENTS = frozenset({ "domain.validated", "dns.applied", }) def _backfill_funnel_notes(conn, session_id: str, ticket_id: int) -> None: """Anexa etapas anteriores ao ticket criado no «Criar servidor».""" sid = (session_id or "").strip() if not sid: return row = conn.execute("SELECT payload FROM tickets WHERE id = ?", (ticket_id,)).fetchone() if not row: return payload = _parse_payload(row["payload"]) notes = list(payload.get("funnel_notes") or []) existing = {n.get("event") for n in notes} rows = conn.execute( """ SELECT event_type, payload, created_at FROM webhook_events WHERE source = ? ORDER BY id ASC """, (ONBOARD_SOURCE,), ).fetchall() for ev_row in rows: ev_payload = _parse_payload(ev_row["payload"]) if (ev_payload.get("session_id") or "").strip() != sid: continue event_type = ev_row["event_type"] if event_type not in FUNNEL_BACKFILL_EVENTS or event_type in existing: continue notes.append({ "event": event_type, "at": ev_row["created_at"], "data": ev_payload.get("data") or {}, "backfilled": True, }) existing.add(event_type) if notes: payload["funnel_notes"] = notes[-30:] conn.execute( "UPDATE tickets SET payload = ? WHERE id = ?", (json.dumps(payload), ticket_id), ) 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 and not (session_id or "").strip(): 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 == "account.created": email = (body.data or {}).get("email") if email: payload["account_email"] = email domain = body.domain or payload.get("domain") or "sem dominio" conn.execute( "UPDATE tickets SET subject = ? WHERE id = ?", (f"[onboarding] {domain} — {email}", tid), ) if event == "onboarding.completed": payload["ready_for_ops"] = True payload["onboarding_outcome"] = "completed" payload["crm_track"] = "onboarding_completed" 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_hours = crm_leads.ONBOARD_STALE_HOURS stale_cutoff = (datetime.now(timezone.utc) - timedelta(hours=stale_hours)).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) assist = assist_store.get_active_assist(conn, sid) ticket_row = assist_store.find_ticket_by_session(conn, sid) crm_track = None if ticket_row: crm_track = _parse_payload(ticket_row["payload"]).get("crm_track") assist_status = "observing" if assist and assist.get("status") == "active": assist_status = "assisting" elif ticket_row and ticket_row["status"] in ("escalated", "assisting"): assist_status = ticket_row["status"] meta = assist_store.session_funnel_meta(conn, sid, FUNNEL_EVENT_RANK, FUNNEL_STAGE_BY_RANK, ONBOARD_SOURCE) 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, "crm_track": crm_track, "is_lead": crm_track == "lead", "assist_status": assist_status, "can_escalate": meta.get("can_escalate", False), "assisted_by": assist.get("initiated_by_user") if assist else (ticket_row["assigned_to"] if ticket_row else None), }) 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 body.event in ("onboarding.started", "account.created"): if email: return f"[onboarding] {domain} — {email}" return f"[onboarding] {domain}" 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 _client_ip_from_request(request: Request | None) -> str | None: if request is None: return None forwarded = request.headers.get("x-forwarded-for") if forwarded: return forwarded.split(",")[0].strip() if request.client: return request.client.host return None def _process_ingress(source_key: str, body: WebhookPayload, client_ip: str | None = None) -> dict: now = datetime.now(timezone.utc).isoformat() stored = body.model_dump() stored["source"] = source_key if client_ip: stored["ingress_client_ip"] = client_ip data = stored.get("data") if not isinstance(data, dict): data = {} if not data.get("client_ip"): data["client_ip"] = client_ip stored["data"] = data payload = json.dumps(stored) duplicate = False ticket_created = False ticket_id: int | None = None webhook_event_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: wh_cur = conn.execute( "INSERT INTO webhook_events (event_type,source,payload,created_at) VALUES (?,?,?,?)", (body.event, source_key, payload, now), ) webhook_event_id = int(wh_cur.lastrowid) if source_key == "vm112-security": from app import security_store as sec_store if body.event in sec_store.AUTO_TICKET_EVENTS: domain_label = body.domain or "sem domínio" subject = f"[security] {domain_label} — {body.event.replace('security.', '')}" session_id = (body.session_id or "").strip() or None cur = conn.execute( """ INSERT INTO tickets (tenant_id, subject, status, payload, created_at, session_id) VALUES (?, ?, 'escalated', ?, ?, ?) """, (sec_store.VM112_TENANT_ID, subject, payload, now, session_id), ) ticket_created = True ticket_id = int(cur.lastrowid) elif _should_create_ticket(source_key, body): session_id = (body.session_id or "").strip() or None initial_status = "escalated" if body.event == "onboarding.failed" else "open" ticket_payload = _parse_payload(payload) if body.event == "onboarding.started": ticket_payload["crm_track"] = "onboarding" ticket_payload["funnel_notes"] = [] cur = conn.execute( """ INSERT INTO tickets (tenant_id,subject,status,payload,created_at,session_id,assigned_to,assigned_at) VALUES (?,?,?,?,?,?,NULL,NULL) """, ( tenant_id, _ticket_subject(body, source_key), initial_status, json.dumps(ticket_payload), now, session_id, ), ) ticket_created = True ticket_id = int(cur.lastrowid) if body.event == "onboarding.started" and session_id: _backfill_funnel_notes(conn, session_id, ticket_id) if body.event == "onboarding.failed" and session_id: process_escalation_webhook(conn, body, now) elif body.event in ASSIST_ESCALATION_EVENTS and (body.session_id or "").strip(): ticket_id = process_escalation_webhook(conn, body, now).get("ticket_id") elif body.event == "onboarding.assist.started" and (body.session_id or "").strip(): from app.assist_routes import process_assist_started ticket_id = process_assist_started(conn, body, now).get("ticket_id") elif body.event == "onboarding.assist.ended" and (body.session_id or "").strip(): from app.assist_routes import process_assist_ended ticket_id = process_assist_ended(conn, body, now).get("ticket_id") 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 in ("company.validated", "account.created"): session_id = (body.session_id or "").strip() or None fallback_payload = _parse_payload(payload) if body.event == "account.created": fallback_payload["crm_track"] = "onboarding" fallback_payload["funnel_notes"] = [] cur = conn.execute( """ INSERT INTO tickets (tenant_id,subject,status,payload,created_at,session_id,assigned_to,assigned_at) VALUES (?,?,?,?,?,?,NULL,NULL) """, ( tenant_id, _ticket_subject(body, source_key), "open", json.dumps(fallback_payload), now, session_id, ), ) ticket_created = True ticket_id = int(cur.lastrowid) if body.event == "company.validated": enriched = _parse_payload(payload) enriched["billing_state"] = "awaiting_billing_validation" conn.execute( "UPDATE tickets SET payload = ? WHERE id = ?", (json.dumps(enriched), ticket_id), ) if source_key == ONBOARD_SOURCE: from app import carbonio_release_store tid = ticket_id if not tid and (body.session_id or "").strip(): tid = _find_ticket_id_by_session(conn, body.session_id or "") from app import billing_store if body.event == "company.validated" and body.domain: billing_store.upsert_from_company_validated( conn, domain=body.domain, session_id=body.session_id, ticket_id=tid, data=body.data, ) carbonio_release_store.upsert_from_webhook( conn, event=body.event, domain=body.domain, session_id=body.session_id, data=body.data, webhook_event_id=webhook_event_id, ticket_id=tid, ) 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}") if source_key == ONBOARD_SOURCE: detail = (body.data or {}).get("email") or body.domain or body.session_id or "" try: push_service.notify_ops_event(body.event, domain=body.domain, detail=str(detail)) except Exception: pass 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") @app.get("/api/health") def health(): redis.from_url(REDIS_URL).ping() return {"status": "ok", "service": "ligbox-ops-api", "version": "0.9.6-spec019-023"} @app.get("/api/v1/integrations") def list_integrations(user: auth.DeskUser = Depends(auth.get_current_user)): 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/integrations/health") def integrations_health(user: auth.DeskUser = Depends(auth.require_internal_or_user)): with db() as conn: return integration_health.build_health_report(conn) @app.post("/api/v1/integrations/onboard/test") def test_onboard_webhook(user: auth.DeskUser = Depends(auth.get_current_user)): if user.role not in ("super_admin", "admin"): raise HTTPException(403, "insufficient permissions") session_id = f"desk-test-{int(datetime.now(timezone.utc).timestamp())}" body = WebhookPayload( event="integration.test", domain="ops-healthcheck.ligbox", session_id=session_id, data={"triggered_by": user.username, "test": True}, ) result = _process_ingress(ONBOARD_SOURCE, body) result["domain"] = body.domain result["session_id"] = session_id result["tested_at"] = datetime.now(timezone.utc).isoformat() result["triggered_by"] = user.username result["message"] = ( "Webhook processado com sucesso. O evento aparece no feed SOC e em Eventos." if not result.get("duplicate") else "Evento duplicado — o pipe está OK, mas este teste já existia na janela de deduplicação." ) return result @app.get("/api/v1/tenants") def list_tenants(user: auth.DeskUser = Depends(auth.get_current_user)): 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), user: auth.DeskUser = Depends(auth.get_current_user), ): if not can_read_tickets(user.role): raise HTTPException(403, "insufficient permissions") with db() as conn: query = f"SELECT {TICKET_COLUMNS} FROM tickets" params: list[Any] = [] clauses = [] if status == "active": clauses.append(f"status IN ({','.join('?' * len(TICKET_ACTIVE_STATUSES))})") params.extend(sorted(TICKET_ACTIVE_STATUSES)) elif status in TICKET_ACTIVE_STATUSES or status == "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 = [_visible_ticket(_enrich_ticket(r), user) 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, user: auth.DeskUser = Depends(auth.get_current_user)): if not can_read_tickets(user.role): raise HTTPException(403, "insufficient permissions") with db() as conn: row = conn.execute( f"SELECT {TICKET_COLUMNS} 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: timeline = _session_timeline(conn, sid) from app.funnel_timing import apply_module_timing enriched, timing_meta = apply_module_timing(timeline) ticket["timeline"] = enriched ticket["related_events"] = enriched[-20:] if timing_meta: ticket["timing"] = timing_meta else: ticket["timeline"] = [] ticket["related_events"] = [] ticket["ready_for_ops"] = (ticket.get("payload") or {}).get("ready_for_ops", False) return _visible_ticket(ticket, user) @app.patch("/api/v1/desk/tickets/{ticket_id}") def update_ticket( ticket_id: int, body: TicketStatusUpdate, user: auth.DeskUser = Depends(auth.get_current_user), ): if body.status is None and body.assigned_to is None: raise HTTPException(400, "status or assigned_to required") if body.status is not None and body.status not in ("open", "closed", "escalated", "assisting", "resolved"): raise HTTPException(400, "status inválido") now = datetime.now(timezone.utc).isoformat() with db() as conn: row = conn.execute( f"SELECT {TICKET_COLUMNS} FROM tickets WHERE id = ?", (ticket_id,), ).fetchone() if not row: raise HTTPException(404, "ticket not found") ticket = _enrich_ticket(row) if body.status is not None and not can_patch_ticket(user.role, ticket, user.username): raise HTTPException(403, "insufficient permissions") if body.assigned_to is not None and not can_assign_ticket(user.role, body.assigned_to, user.username): raise HTTPException(403, "insufficient permissions") if body.status is not None: conn.execute("UPDATE tickets SET status = ? WHERE id = ?", (body.status, ticket_id)) if body.assigned_to is not None: assignee = body.assigned_to.strip().lower() if body.assigned_to else None if assignee == "root": assignee = "root" conn.execute( "UPDATE tickets SET assigned_to = ?, assigned_at = ? WHERE id = ?", (assignee, now if assignee else None, ticket_id), ) conn.commit() row = conn.execute( f"SELECT {TICKET_COLUMNS} FROM tickets WHERE id = ?", (ticket_id,), ).fetchone() return {"ticket": _visible_ticket(_enrich_ticket(row), user)} @app.get("/api/v1/desk/summary") def desk_summary(user: auth.DeskUser = Depends(auth.get_current_user)): with db() as conn: open_count = conn.execute( f"SELECT COUNT(*) c FROM tickets WHERE status IN ({','.join('?' * len(TICKET_ACTIVE_STATUSES))})", tuple(sorted(TICKET_ACTIVE_STATUSES)), ).fetchone()["c"] escalated_count = conn.execute("SELECT COUNT(*) c FROM tickets WHERE status = 'escalated'").fetchone()["c"] assisting_count = conn.execute("SELECT COUNT(*) c FROM tickets WHERE status = 'assisting'").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( f"SELECT {TICKET_COLUMNS} FROM tickets ORDER BY id DESC LIMIT 5" ).fetchall() leads_count = crm_leads.count_leads(conn) summary = { "tickets_open": open_count, "tickets_escalated": escalated_count, "tickets_assisting": assisting_count, "tickets_closed": closed_count, "tickets_total": open_count + closed_count, "leads_abandoned": leads_count, "onboard_stale_hours": crm_leads.ONBOARD_STALE_HOURS, "webhook_events": event_count, "wazuh_events": wazuh_events, "tenants": tenant_count, "recent_tickets": [_enrich_ticket(r) for r in recent], } from app import billing_store with db() as conn: bs = billing_store.summary(conn) summary.update({ "billing_pending": bs["billing_pending"], "billing_active": bs["billing_active"], "billing_total": bs["billing_total"], }) if should_mask_sensitive(user.role): return auth.mask_summary_for_noc(summary) return summary @app.get("/api/v1/webhooks/events") def list_webhook_events( session_id: str | None = Query(default=None), source: str | None = Query(default=None), user: auth.DeskUser = Depends(auth.get_current_user), ): if user.role == "noc" and not source: source = "wazuh" if not can_list_webhook_events(user.role, source): raise HTTPException(403, "insufficient permissions") 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), user: auth.DeskUser = Depends(auth.get_current_user), ): if not can_read_funnel(user.role): raise HTTPException(403, "insufficient permissions") with db() as conn: data = _funnel_summary(conn, window_hours=window_hours) if should_mask_sensitive(user.role): data["active_sessions"] = [ { "session_id": (s.get("session_id") or "")[:8] + "…", "domain": s.get("domain"), "current_stage": s.get("current_stage"), "last_event_at": s.get("last_event_at"), "ticket_id": s.get("ticket_id"), "stale": s.get("stale"), } for s in data.get("active_sessions", []) ] return data @app.get("/api/v1/onboard/sessions/{session_id}/timeline") def onboard_session_timeline(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)): if not can_read_session_timeline(user.role): raise HTTPException(403, "insufficient permissions") 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) result = { "session_id": sid, "domain": domain, "ticket_id": ticket_id, "events": timeline, } from app.modules import store as module_store from app.funnel_timing import build_timing_report from app.funnel_timing import apply_module_timing if module_store.is_module_enabled("funnel-timing") and timeline: enriched, timing_meta = apply_module_timing(timeline) result["events"] = enriched if timing_meta: result["timing"] = timing_meta return result @app.get("/api/v1/audit/overview") def audit_overview(user: auth.DeskUser = Depends(auth.get_current_user)): if not can_read_audit_overview(user.role): raise HTTPException(403, "insufficient permissions") with db() as conn: return audit_store.build_overview(conn) @app.get("/api/v1/audit/tenants/{tenant_id}/details") def audit_tenant_details( tenant_id: int, user: auth.DeskUser = Depends(auth.get_current_user), ): if not can_read_audit_overview(user.role): raise HTTPException(403, "insufficient permissions") with db() as conn: details = audit_store.tenant_details(conn, tenant_id) if not details: raise HTTPException(404, "tenant not found") return details @app.get("/api/v1/dns/cloudflare/records") async def cloudflare_dns_records( domain: str = Query(..., min_length=3), email_service: bool | None = Query(default=None), user: auth.DeskUser = Depends(auth.get_current_user), ): if not can_read_cloudflare_dns(user.role): raise HTTPException(403, "insufficient permissions") domain = domain.lower().strip() return await fetch_domain_dns(domain, email_service=email_service) @app.get("/api/v1/audit/tenants/{tenant_id}/scorecard") def audit_scorecard( tenant_id: int, domain: str = Query(...), user: auth.DeskUser = Depends(auth.get_current_user), ): if not can_read_audit_scorecard(user.role): raise HTTPException(403, "insufficient permissions") 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(...), user: auth.DeskUser = Depends(auth.get_current_user), ): if not can_run_audit(user.role): raise HTTPException(403, "insufficient permissions") 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(user: auth.DeskUser = Depends(auth.require_internal_or_user)): if user.username not in ("worker", "system") and not can_run_audit(user.role): raise HTTPException(403, "insufficient permissions") 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, _client_ip_from_request(request)) @app.post("/api/v1/webhooks/onboard") def webhook_onboard( body: WebhookPayload, request: Request, x_webhook_secret: str | None = Header(default=None), ): _verify_secret("onboard", x_webhook_secret) return _process_ingress("vm112-onboard", body, _client_ip_from_request(request)) @app.post("/api/v1/webhooks/security") def webhook_security( body: WebhookPayload, request: Request, x_webhook_secret: str | None = Header(default=None), ): _verify_secret("security", x_webhook_secret) if not body.event.startswith("security."): raise HTTPException(400, "event must start with security.") return _process_ingress("vm112-security", body, _client_ip_from_request(request)) @app.get("/api/v1/infra/vm112/status") def vm112_status(user: auth.DeskUser = Depends(auth.get_current_user)): 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(user: auth.DeskUser = Depends(auth.get_current_user)): try: with httpx.Client(timeout=8.0, verify=False) as c: r = c.get("https://10.10.10.104:55000/") online = r.status_code in (200, 401) body = r.json() if r.headers.get("content-type", "").startswith("application/json") else r.text[:200] return { "wazuh_api": body, "http_status": r.status_code, "api_online": online, } except Exception as e: return {"wazuh_api": None, "http_status": None, "api_online": False, "error": str(e)}