obsidian-vault/ligbox-ops-platform/app/main.py
2026-06-19 17:26:42 +00:00

774 lines
26 KiB
Python

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)}