Initial import: obsidian-infra vault
This commit is contained in:
commit
a8b62b2de1
227 changed files with 75842 additions and 0 deletions
48
ligbox-ops-platform/.cursor/rules/portugues-brasil.mdc
Normal file
48
ligbox-ops-platform/.cursor/rules/portugues-brasil.mdc
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
---
|
||||||
|
description: Comunicação sempre em português do Brasil (pt-BR)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Idioma — Português do Brasil
|
||||||
|
|
||||||
|
Roger prefere comunicação **somente em português do Brasil (pt-BR)**.
|
||||||
|
|
||||||
|
## Obrigatório
|
||||||
|
|
||||||
|
- Escrever todas as respostas, explicações, relatórios e mensagens ao usuário em **pt-BR**.
|
||||||
|
- Usar vocabulário, ortografia e convenções do **Brasil**, não de Portugal.
|
||||||
|
- Chamar o usuário de **Roger**.
|
||||||
|
|
||||||
|
## Ortografia e vocabulário (pt-BR)
|
||||||
|
|
||||||
|
| Evitar (PT-PT) | Usar (pt-BR) |
|
||||||
|
|----------------|--------------|
|
||||||
|
| activação | ativação |
|
||||||
|
| ecrã | tela |
|
||||||
|
| ficheiro | arquivo |
|
||||||
|
| utilizador | usuário |
|
||||||
|
| correio electrónico | e-mail |
|
||||||
|
| telemóvel | celular |
|
||||||
|
| palavra-passe | senha |
|
||||||
|
| a carregar | carregando |
|
||||||
|
| guardar | salvar |
|
||||||
|
| eliminar | excluir / remover |
|
||||||
|
| registo / registar | cadastro / cadastrar |
|
||||||
|
| sessão terminada | sessão encerrada |
|
||||||
|
| equipa | equipe |
|
||||||
|
| factores | fatores |
|
||||||
|
| actualizar | atualizar |
|
||||||
|
| servidor (no sentido de atendente) | servidor (infra) / atendente (pessoa) |
|
||||||
|
|
||||||
|
## Tom
|
||||||
|
|
||||||
|
- Linguagem técnica clara, direta e profissional.
|
||||||
|
- Frases completas; evitar tom telegráfico.
|
||||||
|
- Manter termos técnicos em inglês quando forem padrão da indústria (API, deploy, SSH, Docker, etc.).
|
||||||
|
|
||||||
|
## UI e documentação do projeto
|
||||||
|
|
||||||
|
- **Todo** texto visível ao usuário (HTML, JS, API, e-mails, tickets) deve estar em **pt-BR**.
|
||||||
|
- Locale de datas: `pt-BR` (nunca `pt-PT`).
|
||||||
|
- Atributo HTML: `lang="pt-BR"`.
|
||||||
|
- Não misturar vocabulário de Portugal no app.
|
||||||
3
ligbox-ops-platform/.specify/feature.json
Normal file
3
ligbox-ops-platform/.specify/feature.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"feature_directory": "specs/003-desk-auth-rbac"
|
||||||
|
}
|
||||||
191
ligbox-ops-platform/BACKLOG.md
Normal file
191
ligbox-ops-platform/BACKLOG.md
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
# Backlog — Ligbox Ops Platform (VM122)
|
||||||
|
|
||||||
|
**Última atualização:** 2026-06-17 (Specs **014–025** + VM123 finance stack)
|
||||||
|
**Projeto:** `ligbox-ops-platform`
|
||||||
|
**VM122:** `ligbox-ops` · `10.10.10.122` · SSH WAN `:2522`
|
||||||
|
**VM112:** Portal/Wizard — integração **API + webhooks** (fora do compose)
|
||||||
|
**VM123:** Finance stack — FOSSBilling + Odoo 16 + OpenPanel · SSH WAN `:2523`
|
||||||
|
|
||||||
|
**Visão:** `docs/architecture/VISAO_PLATAFORMA_LIGBOX_OPS.md`
|
||||||
|
**Specs:** `specs/` (Spec Kit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legenda
|
||||||
|
|
||||||
|
| Prioridade | Significado |
|
||||||
|
|------------|-------------|
|
||||||
|
| **P0** | Bloqueia MVP / produção |
|
||||||
|
| **P1** | Sprint actual |
|
||||||
|
| **P2** | Importante, pós-MVP |
|
||||||
|
| **P3** | Futuro |
|
||||||
|
|
||||||
|
| Estado | Significado |
|
||||||
|
|--------|-------------|
|
||||||
|
| 📋 | Backlog |
|
||||||
|
| 🔄 | Em curso |
|
||||||
|
| ✅ | Concluído |
|
||||||
|
| 🔀 | Consolidada noutra spec |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisões fechadas
|
||||||
|
|
||||||
|
| Data | Tema | Decisão |
|
||||||
|
|------|------|---------|
|
||||||
|
| 2026-06-04 | VM alvo | Ops na VM113 (plano inicial) |
|
||||||
|
| 2026-06-08 | VM alvo | **VM122** criada (8 GB, SQLite MVP) |
|
||||||
|
| 2026-06-08 | Storage | SQLite no MVP (sem Postgres) |
|
||||||
|
| 2026-06-08 | VM112 | **Não** entra no compose — só API/webhooks |
|
||||||
|
| 2026-06-10 | Mail Desk | **VM108** `@ligbox.com.br` via LMTP |
|
||||||
|
| 2026-06-10 | Spec 007 | Push mobile/web — draft (ntfy + PWA) |
|
||||||
|
| 2026-06-10 | Spec 010 | Assist/takeover ASM — **P0**, decisões Roger fechadas |
|
||||||
|
| 2026-06-10 | Spec 011 | OTRS VM112 — stub futuro (pós 010) |
|
||||||
|
| 2026-06-10 | Ticket onboarding | **1 ticket em `onboarding.started`** no «Criar conta» VM112 |
|
||||||
|
| 2026-06-10 | Spec 012 | Abandono → Lead CRM — Fase A+B ✅ |
|
||||||
|
| 2026-06-10 | Spec 013 | Migração e-mail — **migrar antes do DNS** |
|
||||||
|
| 2026-06-16 | Spec 015 | Módulos Desk — activar/desactivar sem quebrar núcleo |
|
||||||
|
| 2026-06-16 | Spec 017/018 | Purge VM112 + Orquestração Serviços (MOSP) |
|
||||||
|
| 2026-06-17 | Spec 023 | Billing Desk Fase 1 — Odoo primário, gateway fase 2 |
|
||||||
|
| 2026-06-17 | Spec 024 | VM123 FOSS + Odoo + OpenPanel · Opção B domínios ligbox |
|
||||||
|
| 2026-06-17 | Spec 025 | Onboarding contínuo — Fase 1 idempotência create |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Specs concluídas
|
||||||
|
|
||||||
|
| # | Feature | Notas |
|
||||||
|
|---|---------|-------|
|
||||||
|
| **001** | `webhook-vm112-integration` | Funil + company gate + tickets |
|
||||||
|
| **002** | `wazuh-integration` | Ingress genérico + VM104 |
|
||||||
|
| **003** | `desk-auth-rbac` | Login JWT, root/admin/mini/noc |
|
||||||
|
| **004** | `desk-account-management` | Cadastro · VM108 · 2-de-3 · TOTP · pt-BR |
|
||||||
|
| **022** | `carbonio-account-exists-release` | Bloqueios Carbonio + zmprov VM112 |
|
||||||
|
|
||||||
|
**API:** `0.9.6-spec019-023`
|
||||||
|
**URLs:** `desk.ligbox.com.br` · `api.ops.ligbox.com.br` · `financeiro.ligbox.com.br` · `openpanel.ligbox.com.br`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fila Spec Kit (014–025)
|
||||||
|
|
||||||
|
| # | Feature | Prioridade | Estado | Pendente principal |
|
||||||
|
|---|---------|------------|--------|-------------------|
|
||||||
|
| **007** | `mobile-push-notifications` | P1 | 📋 | Fases A–C (ntfy + PWA) |
|
||||||
|
| **010** | `desk-assist-takeover` | **P0** | 🔄 | Fase D: push 007, auto-escalada |
|
||||||
|
| **011** | `integration-otrs` | P2 | 📋 | Stub futuro |
|
||||||
|
| **012** | `abandoned-onboarding-lead` | P1 | 🔄 | Fase C outreach · Fase D CRM |
|
||||||
|
| **013** | `email-server-migration` | P0 | 📋 | Design completo — execução em 019 |
|
||||||
|
| **014** | `funnel-phase-timing` | P1 | 🔄 | Validação E2E formal |
|
||||||
|
| **015** | `desk-module-registry` | P0 | 🔄 | Evolução modular contínua |
|
||||||
|
| **016** | `onboard-self-service-prefill` | P0 | 🔄 | Regressão UX / testes |
|
||||||
|
| **017** | `vm112-domain-orchestration` | P1 | 🔄 | Fase 3 VM112 passos tempo real |
|
||||||
|
| **018** | `service-orchestration` | P1 | 🔄 | Fase 2 API clients · Fase 3 multi-wizard |
|
||||||
|
| **019** | `email-migration-vm122-execution` | P0 | 🔄 | PST upload · hook VM112 · piloto |
|
||||||
|
| **020** | `purge-history-desk` | — | 🔀 | Consolidada na **017 v2** |
|
||||||
|
| **021** | `wizard-cybersecurity-telemetry` | P1 | 🔄 | Deploy middleware VM112 · push ntfy |
|
||||||
|
| **023** | `billing-recurrence-desk-visibility` | P1 | 🔄 | **Fase 1 ✅** · Fase 2 gateway ASAAS/Iugu |
|
||||||
|
| **024** | `openpanel-fossbilling` | P1 | ✅ | v1 piloto concluído 17/06 |
|
||||||
|
| **025** | `wizard-onboarding-continuity` | **P0** | 🔄 | **Fase 1 ✅** · Fase 2 resume + RAM 16GB |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Track A — Auditoria & Ops Dashboard
|
||||||
|
|
||||||
|
| ID | P | Item | Estado |
|
||||||
|
|----|---|------|--------|
|
||||||
|
| **OPS-1** | P0 | VM Ops (VM122) Debian 12 + fail2ban | ✅ |
|
||||||
|
| **OPS-2** | P0 | `docker-compose.mvp.yml` | ✅ |
|
||||||
|
| **OPS-3** | P0 | `tenant-registry` (VM112 = 1º nó) | ✅ |
|
||||||
|
| **OPS-7** | P1 | VM123 finance stack (Spec 024) | ✅ |
|
||||||
|
| **AUD-1** | P0 | Collectors: Carbonio, DNS, nginx | 🔄 parcial |
|
||||||
|
| **AUD-2** | P0 | UI `/ops/overview` + API scorecard | 🔄 parcial |
|
||||||
|
| **AUD-3** | P1 | Scorecard por domínio (8 checks) | 🔄 |
|
||||||
|
| **MIG-1** | **P0** | Módulo migração e-mail (Spec 013/019) | 🔄 MVP |
|
||||||
|
| **MIG-2** | **P0** | Gate DNS — migrar antes de MX | 🔄 gate OK |
|
||||||
|
| **MIG-3** | P0 | Pipeline PST (readpst + imap-upload) | 📋 |
|
||||||
|
| **WZ-1** | P1 | Wazuh agent EmailServers + VM123 | 🔄 VM123 ✅ |
|
||||||
|
| **WZ-2** | P2 | UI Wazuh filtro origem | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Track B — Support Desk
|
||||||
|
|
||||||
|
| ID | P | Item | Estado |
|
||||||
|
|----|---|------|--------|
|
||||||
|
| **DESK-1** | P0 | UI tickets + timeline | ✅ MVP |
|
||||||
|
| **DESK-2** | P0 | Modelo tickets + estados SQLite | ✅ |
|
||||||
|
| **INT-2** | P0 | Webhooks VM112 → VM122 | ✅ |
|
||||||
|
| **DESK-4** | **P0** | Assist/takeover ASM — Spec 010 A+B+C+F | 🔄 |
|
||||||
|
| **DESK-5** | P1 | Orquestração Serviços MOSP (018) | 🔄 Fase 1 |
|
||||||
|
| **DESK-6** | P1 | Billing visibilidade 💳 (023 Fase 1) | ✅ |
|
||||||
|
| **INT-1** | P2 | OTRS API bridge — Spec 011 | 📋 |
|
||||||
|
| **DESK-3** | P2 | Kanban, SLA (após Spec 010) | 📋 → Spec 008 |
|
||||||
|
| **AG-1** | P3 | Agentes IA + runbooks | 📋 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Track RBAC & Auth
|
||||||
|
|
||||||
|
| ID | P | Item | Estado |
|
||||||
|
|----|---|------|--------|
|
||||||
|
| **OPS-4** | P0 | RBAC: super_admin, ops_lead, technician, noc | ✅ |
|
||||||
|
| **OPS-6** | P0 | Auth JWT Desk (login UI) | ✅ |
|
||||||
|
| **OPS-5** | P2 | Roles client_domain_admin (futuro) | 📋 |
|
||||||
|
|
||||||
|
### Utilizadores Desk (VM122)
|
||||||
|
|
||||||
|
| User | Role | Função |
|
||||||
|
|------|------|--------|
|
||||||
|
| `root` | super_admin | Roger — tudo |
|
||||||
|
| `admin` | ops_lead | Chefe ops |
|
||||||
|
| `mini` | technician | Suporte N1/N2 |
|
||||||
|
| `noc` | noc | Monitorização (leitura) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VM123 — Finance Stack (Spec 024)
|
||||||
|
|
||||||
|
| Serviço | URL | Estado |
|
||||||
|
|---------|-----|--------|
|
||||||
|
| FOSSBilling Admin | `https://financeiro.ligbox.com.br/admin` | ✅ HTTPS |
|
||||||
|
| FOSSBilling Cliente | `https://financeiro.ligbox.com.br/login` | ✅ |
|
||||||
|
| Odoo 16 | `https://financeiro.ligbox.com.br/odoo/web/login?db=ligbox` | ✅ |
|
||||||
|
| OpenPanel | `https://openpanel.ligbox.com.br` | ✅ |
|
||||||
|
| OpenAdmin | `https://admin.openpanel.ligbox.com.br` | ✅ |
|
||||||
|
| Bridge Community API | `http://10.10.10.123:18087` | ✅ |
|
||||||
|
|
||||||
|
**Credenciais:** `deploy/vm123-finance-stack/CREDENCIAIS_SERVICOS_VM123.txt`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prioridades P0/P1 — próximo sprint
|
||||||
|
|
||||||
|
1. **025 Fase 2** — resume wizard + VM112 16 GB + Traefik YAML validation
|
||||||
|
2. **025 Fase 2** — resume wizard + VM112 16 GB + Traefik YAML validation
|
||||||
|
3. **023 Fase 2** — gateway pagamento (ASAAS vs Iugu)
|
||||||
|
4. **019** — piloto migração real + hook VM112 gate DNS
|
||||||
|
5. **018 Fase 2** — API `clients` + `service_instances`
|
||||||
|
6. **012 Fase C** — outreach abandonos
|
||||||
|
7. **007** — push ntfy (desbloqueia 010-D e 021)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Portal VM112 (repo separado)
|
||||||
|
|
||||||
|
| ID | Item | Estado |
|
||||||
|
|----|------|--------|
|
||||||
|
| OPS-1/2 diarissima | DNS + LE + webmail | ✅ |
|
||||||
|
| WIZ-025 | Onboarding contínuo Fase 1 | ✅ |
|
||||||
|
| SUP-3.2 | OTRS no `/escalate` | 📋 → Spec **011** |
|
||||||
|
| SUP-4.1/4.2 | Painel humano ASM + SLA cliente | 📋 → Spec **010** |
|
||||||
|
| PRD-3 | Painel corporativo UI | 📋 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Como actualizar
|
||||||
|
|
||||||
|
- Spec concluída → actualizar esta tabela + `specs/NNN/tasks.md`
|
||||||
|
- Sync Obsidian: `rsync -av /opt/ligbox-ops-platform/ /root/obsidian-infra/ligbox-ops-platform/`
|
||||||
|
- GitHub: `itecnologys/ligbox-ops-platform`
|
||||||
|
- Deploy VM122: `/opt/ligbox-ops-platform/`
|
||||||
9
ligbox-ops-platform/Dockerfile
Normal file
9
ligbox-ops-platform/Dockerfile
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
FROM python:3.11-slim-bookworm
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends dnsutils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY app ./app
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
# Feature Specification: Migração E-mail Legado — Execução VM122 (019)
|
||||||
|
|
||||||
|
**Criado:** 2026-06-16
|
||||||
|
**Solicitado por:** Roger
|
||||||
|
**Status:** 📋 Aprovado para planeamento / implementação
|
||||||
|
**Prioridade:** **P0**
|
||||||
|
**Depende de:** Spec 013 (modelo completo), Spec 010 (tickets), Spec 018 (Serviços MOSP)
|
||||||
|
**Wizard cliente:** permanece na **VM112** — **não** executa migração legada
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumo executivo
|
||||||
|
|
||||||
|
| Onde | O quê |
|
||||||
|
|------|--------|
|
||||||
|
| **VM112** | Wizard onboarding — criar domínio/conta Carbonio, DNS **só após gate** |
|
||||||
|
| **VM122** | **Orquestração OPS** — migrar e-mail do servidor **anterior/legado** → Carbonio VM112 |
|
||||||
|
|
||||||
|
**Regra de ouro (Roger):**
|
||||||
|
**Migrar → validar → aprovar gate → só depois virar DNS (MX).**
|
||||||
|
|
||||||
|
O cliente **não** vê imapsync nem PST no wizard. O técnico sénior opera no **Desk VM122** (vista Email Migration + ticket).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Porquê VM122 e não VM112?
|
||||||
|
|
||||||
|
| Critério | VM112 (wizard) | VM122 (Desk) |
|
||||||
|
|----------|----------------|--------------|
|
||||||
|
| Público | Cliente final | Técnico OPS |
|
||||||
|
| Duração | minutos | horas / dias |
|
||||||
|
| Credenciais servidor antigo | ❌ nunca | ✅ vault encriptado |
|
||||||
|
| Ferramentas pesadas (imapsync, PST) | ❌ | ✅ worker/host |
|
||||||
|
| Auditoria / ticket | parcial | completa |
|
||||||
|
| Gate antes DNS | consulta API | controla e aprova |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ferramentas GitHub (rápidas e seguras)
|
||||||
|
|
||||||
|
| Ferramenta | Repositório | Uso | Maturidade |
|
||||||
|
|------------|-------------|-----|------------|
|
||||||
|
| **imapsync** | [imapsync/imapsync](https://github.com/imapsync/imapsync) | IMAP → IMAP (cPanel, Zimbra, O365, Gmail…) | ⭐ ~4k — **padrão indústria** |
|
||||||
|
| **imap-upload** | [rgladwell/imap-upload](https://github.com/rgladwell/imap-upload) | mbox → IMAP (pós readpst) | Complemento PST |
|
||||||
|
| **readpst** | `pst-utils` (Debian) | Extrair PST Outlook | Sistema |
|
||||||
|
| **zmmailbox TGZ** | Carbonio nativo | Zimbra/Carbonio → Carbonio | Oficial Zextras |
|
||||||
|
| **oauth2_imap** | imapsync.lamiral.info | O365 / Gmail moderno | Obrigatório se Basic Auth off |
|
||||||
|
|
||||||
|
**Não recomendado MVP:** ferramentas comerciais fechadas, scripts aleatórios sem logs, migração manual sem gate.
|
||||||
|
|
||||||
|
### Boas práticas imapsync (oficial)
|
||||||
|
|
||||||
|
1. `--justlogin` + `--dry` + `--justfolders` **antes** do sync real
|
||||||
|
2. Credenciais em **ficheiro 600**, nunca na linha de comando
|
||||||
|
3. **Presync** (bulk) com MX ainda no servidor antigo
|
||||||
|
4. **Delta sync** agendado (6/6h)
|
||||||
|
5. **Sync final** na janela de cutover
|
||||||
|
6. `--maxbytespersecond` se origem limitar rate
|
||||||
|
7. O365: **OAuth2**, não password básica
|
||||||
|
|
||||||
|
Fontes: [FAQ Migration Plan](https://imapsync.lamiral.info/FAQ.d/FAQ.Migration_Plan.txt), [FAQ Massive](https://github.com/imapsync/imapsync/blob/master/FAQ.d/FAQ.Massive.txt)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura VM122
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Desk VM122 (ligbox-ops-platform) │
|
||||||
|
│ UI: Email Migration │ API /api/v1/migration/* │
|
||||||
|
│ Worker + ferramentas │ Gate DNS → bloqueia wizard VM112 │
|
||||||
|
└────────────┬───────────────────────────────┬────────────────┘
|
||||||
|
│ imapsync / PST pipeline │ GET /migration/gate
|
||||||
|
▼ ▼
|
||||||
|
Servidor LEGADO (host1) VM112 Carbonio (host2)
|
||||||
|
cPanel / Zimbra / O365 mail.{dominio}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Onde correm as ferramentas
|
||||||
|
|
||||||
|
| Fase piloto | Host |
|
||||||
|
|-------------|------|
|
||||||
|
| **Agora** | VM122 host ou container worker (fora da API) |
|
||||||
|
| **Produção volume** | VM123 dedicada `ligbox-migration` (Spec 013 infrastructure.md) |
|
||||||
|
|
||||||
|
**Nunca** dentro do container API FastAPI (bloqueia event loop, sem ferramentas).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxo operacional (técnico sénior)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant T as Técnico Desk
|
||||||
|
participant V122 as VM122 API/Worker
|
||||||
|
participant LEG as Servidor legado
|
||||||
|
participant V112 as Carbonio VM112
|
||||||
|
participant W as Wizard VM112
|
||||||
|
|
||||||
|
T->>V122: Criar job migração (domínio, mailboxes)
|
||||||
|
T->>V122: Preflight (--justlogin)
|
||||||
|
V122->>LEG: Teste IMAP origem
|
||||||
|
V122->>V112: Teste IMAP destino
|
||||||
|
T->>V122: Sync initial (MX ainda no legado)
|
||||||
|
V122->>LEG: imapsync bulk
|
||||||
|
V122->>V112: grava mensagens
|
||||||
|
loop Delta
|
||||||
|
T->>V122: Sync delta
|
||||||
|
end
|
||||||
|
T->>V122: Verify ≥99%
|
||||||
|
T->>V122: Approve gate (ops_lead)
|
||||||
|
W->>V122: GET /migration/gate?domain=
|
||||||
|
V122-->>W: ready_for_dns
|
||||||
|
T->>W: Cutover DNS (ou assist)
|
||||||
|
T->>V122: Sync final
|
||||||
|
T->>V122: Close job + relatório ticket
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integração com wizard VM112
|
||||||
|
|
||||||
|
| Momento | VM112 | VM122 |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| Cliente cria conta | ✅ wizard | job `discovered` manual ou webhook |
|
||||||
|
| Contas destino Carbonio | ✅ zmprov via wizard | preflight confirma |
|
||||||
|
| Aplicar MX Cloudflare | ⚠️ **bloqueado** se gate ≠ `ready_for_dns` | gate API |
|
||||||
|
| Override emergência | — | `super_admin` + motivo auditado |
|
||||||
|
|
||||||
|
**Implementação gate (Fase B):**
|
||||||
|
`GET /api/v1/migration/gate?domain=` — VM112 chama antes de `dns.applied` final.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fases e critérios (resumo Spec 013)
|
||||||
|
|
||||||
|
| Fase | DNS virado? | Acção |
|
||||||
|
|------|-------------|-------|
|
||||||
|
| discovered | Não | Inventário mailboxes |
|
||||||
|
| preflight | Não | Testes login + mapeamento pastas |
|
||||||
|
| initial_sync | Não | imapsync bulk |
|
||||||
|
| delta_sync | Não | incrementais |
|
||||||
|
| cutover_ready | Não | verify ≥99%, aprovação ops_lead |
|
||||||
|
| dns_cutover | **Sim** | MX → VM112 |
|
||||||
|
| final_sync | Sim | última delta |
|
||||||
|
| verified / closed | Sim | relatório ticket |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Matriz de risco (Roger)
|
||||||
|
|
||||||
|
| Risco | Nível | Impacto | Mitigação |
|
||||||
|
|-------|-------|---------|-----------|
|
||||||
|
| Virar MX antes da migração | 🔴 **Crítico** | Perda de e-mail novo + antigo separados | **Gate API** + procedimento OPS |
|
||||||
|
| PST corrompido | 🟠 Alto | Gaps silenciosos | readpst + quarentena + verify |
|
||||||
|
| O365 Basic Auth bloqueado | 🟠 Alto | Sync falha | OAuth2 (`oauth2_imap`) |
|
||||||
|
| Duplicatas em re-sync | 🟡 Médio | Inbox duplicado | imapsync Message-Id; não misturar PST+imap mesma pasta |
|
||||||
|
| Rate limit servidor origem | 🟡 Médio | IP banido | `--maxbytespersecond`, horários off-peak |
|
||||||
|
| Mailbox gigante (50GB+) | 🟡 Médio | Timeout | sync por pasta; worker 24h retomável |
|
||||||
|
| Credenciais em log | 🔴 Crítico | Compromisso contas | vault Fernet; passfile 600 |
|
||||||
|
| Carga VM122 | 🟡 Médio | Desk lento | worker separado / VM123 futuro |
|
||||||
|
| Cliente envia mail durante cutover | 🟡 Médio | Algumas msgs no legado | sync final + TTL MX baixo pré-cutover |
|
||||||
|
|
||||||
|
**Nível global da etapa:** 🟠 **ALTO** — dados de produção irreversíveis se mal executado.
|
||||||
|
**Com Spec 013 + gate + presync:** 🟡 **MÉDIO controlável** para técnico sénior com runbook.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plano de implementação (como vamos proceder)
|
||||||
|
|
||||||
|
### Fase A — Fundação (VM122, ~1 sprint)
|
||||||
|
|
||||||
|
1. Schema SQLite (`migration_jobs`, `mailboxes`, `runs`, `credentials`) — Spec 013 data-model
|
||||||
|
2. `install-migration-tools.sh` na VM122 (imapsync, pst-utils, imap-upload)
|
||||||
|
3. API CRUD jobs + preflight `--justlogin`
|
||||||
|
4. Worker `migration_runner.py` — 1 mailbox imapsync
|
||||||
|
5. UI Desk mínima: lista jobs + log
|
||||||
|
|
||||||
|
### Fase B — Gate DNS (~½ sprint)
|
||||||
|
|
||||||
|
6. `gate.py` — ratio 99%, estados blocked/warning/ready
|
||||||
|
7. `GET /migration/gate?domain=` para VM112
|
||||||
|
8. Integração ticket + notas por `migration_run`
|
||||||
|
|
||||||
|
### Fase C — PST + verify (~1 sprint)
|
||||||
|
|
||||||
|
9. Upload PST multipart
|
||||||
|
10. Pipeline readpst → imap-upload
|
||||||
|
11. Relatório verify + approve-gate
|
||||||
|
|
||||||
|
### Fase D — VM112 hook (~½ sprint)
|
||||||
|
|
||||||
|
12. VM112: antes DNS final, consultar gate
|
||||||
|
13. Override auditado super_admin
|
||||||
|
|
||||||
|
### Piloto obrigatório
|
||||||
|
|
||||||
|
- **1 domínio teste** (não produção crítica)
|
||||||
|
- Origem: cPanel ou Zimbra conhecido
|
||||||
|
- Destino: Carbonio VM112 tenant teste
|
||||||
|
- Só depois: cliente real com legado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API (referência — Spec 013)
|
||||||
|
|
||||||
|
| Método | Path |
|
||||||
|
|--------|------|
|
||||||
|
| POST | `/api/v1/migration/jobs` |
|
||||||
|
| POST | `/api/v1/migration/jobs/{id}/preflight` |
|
||||||
|
| POST | `/api/v1/migration/jobs/{id}/sync` |
|
||||||
|
| GET | `/api/v1/migration/jobs/{id}/verify` |
|
||||||
|
| GET | `/api/v1/migration/gate?domain=` |
|
||||||
|
| POST | `/api/v1/migration/jobs/{id}/approve-gate` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fora de escopo desta spec
|
||||||
|
|
||||||
|
- Migração no wizard Hero VM112
|
||||||
|
- Calendário/contactos CardDAV (só e-mail)
|
||||||
|
- VM123 provisionamento (até volume exigir)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentos relacionados
|
||||||
|
|
||||||
|
- `specs/013-email-server-migration/spec.md` — spec completa
|
||||||
|
- `specs/013-email-server-migration/research.md` — ferramentas GitHub
|
||||||
|
- `specs/013-email-server-migration/plan.md` — ficheiros código
|
||||||
|
- `specs/013-email-server-migration/quickstart.md` — runbook técnico
|
||||||
|
- `specs/013-email-server-migration/tasks.md` — checklist T001–T040
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critérios de aceite execução VM122
|
||||||
|
|
||||||
|
- [ ] imapsync instalado e `--justlogin` OK VM122 → legado + Carbonio
|
||||||
|
- [ ] Job piloto cPanel/Zimbra → Carbonio sem perda Inbox/Sent
|
||||||
|
- [ ] Gate bloqueia DNS com sync < 99%
|
||||||
|
- [ ] Gate libera com aprovação ops_lead + relatório
|
||||||
|
- [ ] Wizard VM112 respeita gate (ou override auditado)
|
||||||
|
- [ ] Zero credenciais origem em logs Desk
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
# Anais de Referência — Serviços MOSP, Orquestração VM122, Purge SSE/Jobs
|
||||||
|
|
||||||
|
**Data:** 2026-06-16
|
||||||
|
**Utilizador:** Roger
|
||||||
|
**Transcript Cursor:** `ad3c7400-04ce-47bf-8995-2861d54a831b`
|
||||||
|
**Projeto:** Ligbox Ops Platform · Desk VM122 (`10.10.10.122:8091`) + Wizard VM112 (`10.10.10.112`)
|
||||||
|
**Chat bruto:** `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.{txt,jsonl}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Resumo executivo
|
||||||
|
|
||||||
|
Sessão focada em **orquestração MOSP no Desk** (não no wizard VM112):
|
||||||
|
|
||||||
|
1. Página **Serviços** (ex-Contas/Overview Home) — clientes + tenants de oferta + purge Spec 017.
|
||||||
|
2. Spec **018** — modelo Pizza as a Service / MOSP / catálogo multi-produto.
|
||||||
|
3. Purge com painel lateral timeline + **SSE** + **jobs async/polling** (fix 504 / Failed to fetch).
|
||||||
|
4. Purges testados: `dratcoin.com`, `eplacebets.com` — UI falhou mas backend concluiu.
|
||||||
|
5. **Fase 3 pendente VM112** — passos Carbonio/CF/Traefik em tempo real dentro do purge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Specs criadas/actualizadas
|
||||||
|
|
||||||
|
| Spec | Path | Estado |
|
||||||
|
|------|------|--------|
|
||||||
|
| 017 | `specs/017-vm112-domain-orchestration/spec.md` | Purge domínio — Fase 1 concluída |
|
||||||
|
| 018 | `specs/018-service-orchestration/spec.md` | MOSP, Pizza as a Service, Fase 1 UI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UI Desk — menu Serviços
|
||||||
|
|
||||||
|
| Item | Valor |
|
||||||
|
|------|-------|
|
||||||
|
| Módulo ID | `overview-home` (sem breaking change) |
|
||||||
|
| Menu | **Serviços** |
|
||||||
|
| Título | Orquestração de Serviços |
|
||||||
|
| Subtítulo | Desk VM122 · Orquestração MOSP |
|
||||||
|
| Layout | 3 colunas: Clientes · Tenants de Oferta · Escopo OPS |
|
||||||
|
|
||||||
|
**Ficheiros principais:**
|
||||||
|
- `frontend/assets/accounts.js` → `DeskServices`
|
||||||
|
- `frontend/assets/styles.css` → `.servicos-*`, `.vm112-purge-drawer`
|
||||||
|
- `frontend/index.html` → modal + drawer purge
|
||||||
|
- `api/app/modules/registry.py`
|
||||||
|
|
||||||
|
**Regra:** cada oferta MOSP terá **wizard próprio**; VM112 Hero = só e-mail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API Desk — domínios VM112
|
||||||
|
|
||||||
|
| Método | Path | Uso |
|
||||||
|
|--------|------|-----|
|
||||||
|
| GET | `/api/v1/vm112/domains` | Lista clientes Fase 1 |
|
||||||
|
| GET | `/api/v1/vm112/domains/{domain}` | Detalhe modal |
|
||||||
|
| POST | `/api/v1/vm112/domains/{domain}/purge` | Purge síncrono (legado) |
|
||||||
|
| POST | `/api/v1/vm112/domains/{domain}/purge/stream` | SSE timeline |
|
||||||
|
| POST | `/api/v1/vm112/domains/{domain}/purge/jobs` | **Recomendado** — job async |
|
||||||
|
| GET | `/api/v1/vm112/purge/jobs/{job_id}` | Poll timeline 2s |
|
||||||
|
|
||||||
|
**Ficheiros API:**
|
||||||
|
- `api/app/vm112_domains.py`
|
||||||
|
- `api/app/vm112_domains_routes.py`
|
||||||
|
- `api/app/vm112_purge_stream.py`
|
||||||
|
- `api/app/vm112_purge_jobs.py`
|
||||||
|
|
||||||
|
**RBAC:** `super_admin`, `ops_lead` + senha Root no purge.
|
||||||
|
**Blocklist:** `ligbox.com.br`, `itecnologys.com`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Purge — incidentes e fixes
|
||||||
|
|
||||||
|
### 504 Gateway Timeout (~60s)
|
||||||
|
- **Causa:** nginx proxy timeout 60s; purge VM112 demora minutos.
|
||||||
|
- **Fix:** `frontend/nginx.conf` → `proxy_read_timeout 600s`, `proxy_buffering off`.
|
||||||
|
|
||||||
|
### Failed to fetch (~79s) via `desk.ligbox.com.br`
|
||||||
|
- **Causa:** Traefik/SSE ligação longa cortada; browser perde stream.
|
||||||
|
- **Fix:** purge **async jobs + polling** (pedidos curtos GET a cada 2s).
|
||||||
|
- **Nota:** purge **concluiu** mesmo com erro UI (`dratcoin`, `eplacebets` sumiram da lista).
|
||||||
|
|
||||||
|
### Poll automático página Serviços (piscava)
|
||||||
|
- **Causa:** `refresh()` 30s re-renderizava com «A carregar…»
|
||||||
|
- **Fix:** poll silencioso em `renderPage({ poll: true })`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Domínios VM112 (fim de sessão)
|
||||||
|
|
||||||
|
Após purges teste, lista típica:
|
||||||
|
- `betinsport.com`, `diarissima.com`, `ibytera.com`, `itecnologys.com`, `myvexx.com`
|
||||||
|
- Removidos: `dratcoin.com`, `eplacebets.com` (testes purge)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/ligbox-ops-platform
|
||||||
|
docker-compose -f docker-compose.mvp.yml build api frontend
|
||||||
|
docker-compose -f docker-compose.mvp.yml up -d api frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
**URLs:**
|
||||||
|
- Desk: `http://10.10.10.122:8091` / `https://desk.ligbox.com.br`
|
||||||
|
- API: `http://10.10.10.122:8080`
|
||||||
|
- Wizard: `https://onboard.ligbox.com.br` (VM112)
|
||||||
|
|
||||||
|
**Hard refresh:** Ctrl+Shift+R após deploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Reteste E2E wizard e-mail
|
||||||
|
|
||||||
|
1. Desk → Serviços → purge domínio teste (se existir)
|
||||||
|
2. Portal onboard → Self-Service → `/onboard`
|
||||||
|
3. Domínio → DNS → conta → infra
|
||||||
|
4. Desk → Serviços → Actualizar → cliente reaparece
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Próximo passo — VM112 (Fase 3)
|
||||||
|
|
||||||
|
**Não implementado nesta sessão** (sem SSH VM112):
|
||||||
|
|
||||||
|
- `domain_orchestration.py` — purge passo a passo com eventos
|
||||||
|
- `POST /api/admin/domains/{domain}/purge/jobs` na VM112
|
||||||
|
- Desk proxy eventos VM112 para drawer timeline
|
||||||
|
|
||||||
|
**Path produção VM112:** `/opt/ligbox-wizard`
|
||||||
|
**SSH:** `root@10.10.10.112` (credencial user rule: `@betinplace`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Canais de arquivo
|
||||||
|
|
||||||
|
| Canal | Path |
|
||||||
|
|-------|------|
|
||||||
|
| Anais VM122 | `/opt/ligbox-ops-platform/docs/anais-referencia/` |
|
||||||
|
| Chat bruto projeto | `/opt/ligbox-ops-platform/chat-bruto/` |
|
||||||
|
| Chat bruto central | `/root/ligbox-ops-platform-chat-bruto/` |
|
||||||
|
| Obsidian | `/root/obsidian-infra/ligbox-ops-platform/` |
|
||||||
|
| LAPTOP | `/opt/ligbox-ops-platform/LAPTOP/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Decisões Roger (registo)
|
||||||
|
|
||||||
|
- MOSP planeado no **Desk VM122**, não na Hero VM112.
|
||||||
|
- Cada oferta = wizard próprio (Proxmox, servidor físico, etc.).
|
||||||
|
- Modelo comercial Pizza as a Service documentado na Spec 018.
|
||||||
|
- Purge Spec 017 mantido; UI evolui (drawer + jobs).
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
# Anais de Referência — VM123 Finance Stack · FOSS + Odoo + OpenPanel (Spec 024)
|
||||||
|
|
||||||
|
**Data:** 2026-06-17
|
||||||
|
**Utilizador:** Roger
|
||||||
|
**Projeto:** Ligbox Ops Platform · VM123 (`10.10.10.123`)
|
||||||
|
**Spec:** `specs/024-openpanel-fossbilling/`
|
||||||
|
**Deploy:** `deploy/vm123-finance-stack/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisões desta sessão
|
||||||
|
|
||||||
|
| Tema | Decisão |
|
||||||
|
|------|---------|
|
||||||
|
| Stack | FOSSBilling + Odoo 16 (Docker) + OpenPanel (bare metal) |
|
||||||
|
| Domínios | **Opção B** — marca `ligbox.com.br` |
|
||||||
|
| FOSSBilling | `https://financeiro.ligbox.com.br/foss` |
|
||||||
|
| Odoo 16 | `https://financeiro.ligbox.com.br/odoo` |
|
||||||
|
| OpenPanel | `https://openpanel.ligbox.com.br` (subdomínio dedicado) |
|
||||||
|
| Integração | FOSS → OpenPanel via API :2087 (módulo GitHub) |
|
||||||
|
| Odoo | ERP interno — sync com FOSS/OpenPanel = fase 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estado VM123 (2026-06-17)
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| VM Proxmox 123 `vm123-finance` | ✅ running |
|
||||||
|
| IP / GW | `10.10.10.123/24` · gw `10.10.10.1` |
|
||||||
|
| SSH LAN | ✅ `root@10.10.10.123` |
|
||||||
|
| Bootstrap (swap, fail2ban, UFW) | ✅ |
|
||||||
|
| DNS fix pós-clone | ✅ `resolv.conf` estático `1.1.1.8` |
|
||||||
|
| Docker FOSS + Odoo | ✅ ports `:8092` `:8069` |
|
||||||
|
| Wizard FOSS / Odoo | ⏳ pendente |
|
||||||
|
| OpenPanel install | ⏳ pendente |
|
||||||
|
| Traefik CT114 rotas | ⏳ pendente confirmação Roger |
|
||||||
|
| DNS Cloudflare | ⏳ pendente |
|
||||||
|
| DNAT SSH WAN `:2523` | ⏳ pendente pfSense |
|
||||||
|
|
||||||
|
**OS:** Debian 13 (clone VM121) · **RAM:** 4 GB + swap 2 GB · **Disco:** ~60 GB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```
|
||||||
|
Traefik CT114
|
||||||
|
│
|
||||||
|
├── financeiro.ligbox.com.br/foss → VM123:8092 (FOSSBilling)
|
||||||
|
├── financeiro.ligbox.com.br/odoo → VM123:8069 (Odoo 16)
|
||||||
|
└── openpanel.ligbox.com.br → VM123:2083 (OpenPanel host)
|
||||||
|
|
||||||
|
VM123 Docker: fossbilling + mariadb + odoo + postgres
|
||||||
|
VM123 host: OpenPanel Enterprise (NÃO Docker)
|
||||||
|
FOSSBilling ──API :2087──► OpenPanel (provisionar hosting)
|
||||||
|
Desk VM122 ──links──► financeiro.ligbox.com.br/foss
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credenciais
|
||||||
|
|
||||||
|
Ficheiro dedicado (mesmo conteúdo):
|
||||||
|
`CREDENCIAIS_LIGBOX_OPS_AMBIENTES_20260617.txt`
|
||||||
|
|
||||||
|
### Desk VM122 — `desk.ligbox.com.br`
|
||||||
|
|
||||||
|
| User | Senha | Papel |
|
||||||
|
|------|-------|-------|
|
||||||
|
| root | `gsq9qtIUD6SQ45Egm8yP` | super_admin |
|
||||||
|
| admin | `gsq9qtIUD6SQ45Egm8yP` | ops_lead |
|
||||||
|
| mini | `gsq9qtIUD6SQ45Egm8yP` | technician |
|
||||||
|
| noc | `gsq9qtIUD6SQ45Egm8yP` | noc |
|
||||||
|
|
||||||
|
SSH Linux VM122: `root` / `805353`
|
||||||
|
**Nota:** `805353` não funciona no login Desk (rotacionada 2026-06-10).
|
||||||
|
|
||||||
|
### VM123 Finance — `10.10.10.123`
|
||||||
|
|
||||||
|
| User | Senha | Uso |
|
||||||
|
|------|-------|-----|
|
||||||
|
| root | `805353` | SSH |
|
||||||
|
| admin | `805353` | sudo |
|
||||||
|
| mini | `805353` | automação |
|
||||||
|
|
||||||
|
**Docker `.env`** (`/opt/vm123-finance-stack/.env`):
|
||||||
|
|
||||||
|
| Variável | Valor |
|
||||||
|
|----------|-------|
|
||||||
|
| FOSS_MARIADB_PASSWORD | `LbFoss9367c416` |
|
||||||
|
| ODOO_DB_PASSWORD | `LbOdood9ca25c3` |
|
||||||
|
| FOSSBILLING_URL | `https://financeiro.ligbox.com.br/foss` |
|
||||||
|
| ODOO_URL | `https://financeiro.ligbox.com.br/odoo` |
|
||||||
|
| OPENPANEL_DOMAIN | `openpanel.ligbox.com.br` |
|
||||||
|
|
||||||
|
FOSSBilling admin / Odoo master: **ainda não configurados** (wizards).
|
||||||
|
|
||||||
|
### Wizard VM112 — `10.10.10.112`
|
||||||
|
|
||||||
|
| User | Senha |
|
||||||
|
|------|-------|
|
||||||
|
| root SSH | `@betinplace` |
|
||||||
|
|
||||||
|
API admin key: `ibytera-corp-api-key-change-later`
|
||||||
|
|
||||||
|
### Traefik CT114 — `10.10.10.114`
|
||||||
|
|
||||||
|
| User | Senha |
|
||||||
|
|------|-------|
|
||||||
|
| root SSH | `805353` |
|
||||||
|
|
||||||
|
### Proxmox — `10.10.10.2:8006`
|
||||||
|
|
||||||
|
| User | Senha |
|
||||||
|
|------|-------|
|
||||||
|
| root@pam | `@betinplace` |
|
||||||
|
|
||||||
|
SSH host: fechado · API: OK
|
||||||
|
|
||||||
|
### pfSense API
|
||||||
|
|
||||||
|
| User | Senha |
|
||||||
|
|------|-------|
|
||||||
|
| api_cursor | `805353` |
|
||||||
|
| user_api | `@betinplace` |
|
||||||
|
|
||||||
|
URL: `https://firewall.itecnologys.com/api/v2/`
|
||||||
|
API Key: `7015072cb259165a3ac4b304f556d035`
|
||||||
|
|
||||||
|
### Tokens internos Desk (`.env` VM122)
|
||||||
|
|
||||||
|
| Item | Valor |
|
||||||
|
|------|-------|
|
||||||
|
| JWT_SECRET | `e4b303fe43f8b24b1d924f5ab235d2cea3657b6cd132c925ce60280c64c87ade` |
|
||||||
|
| OPS_INTERNAL_TOKEN | `128b96e7c12d9b391edbc727880fbdc905b60fa59b52a865` |
|
||||||
|
| WEBHOOK_SECRET | `ligbox-ops-dev-secret` |
|
||||||
|
| VM112_ASSIST_TOKEN | `ligbox-desk-assist-7f3a9c2e1b8d4f06` |
|
||||||
|
| DESK_BOOTSTRAP_PASSWORD | `gsq9qtIUD6SQ45Egm8yP` |
|
||||||
|
|
||||||
|
### Cloudflare API
|
||||||
|
|
||||||
|
| Conta | Token |
|
||||||
|
|-------|-------|
|
||||||
|
| DNS ligbox | `EYH0ZbKTI41f1O0EoW5uxGUUCA3-Fsrt6b4-1xYJ` |
|
||||||
|
| ligbox.com.br | `UBvRO4URpoGPH-vgjVRfKWOpklvmD9vV9PRX43mP` |
|
||||||
|
| DNS extra | `cGjq1sABVWq98eiq9DZACleefcVBBGwpR9Foh3X8` |
|
||||||
|
|
||||||
|
### Odoo V16 (API externa)
|
||||||
|
|
||||||
|
API Key: `813f08e77c858c573e8b7d10d1304dac9e073c8e`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ficheiros alterados
|
||||||
|
|
||||||
|
| Path | Alteração |
|
||||||
|
|------|-----------|
|
||||||
|
| `specs/024-openpanel-fossbilling/spec.md` | Domínios ligbox.com.br |
|
||||||
|
| `deploy/vm123-finance-stack/.env.example` | URLs ligbox |
|
||||||
|
| `deploy/vm123-finance-stack/traefik-routes-snippet.yml` | Hosts ligbox |
|
||||||
|
| `deploy/vm123-finance-stack/install-openpanel.sh` | Domínio default ligbox |
|
||||||
|
| `deploy/vm123-finance-stack/README.md` | URLs + DNS |
|
||||||
|
| VM123 `/opt/vm123-finance-stack/.env` | Aplicado em produção |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Próximos passos
|
||||||
|
|
||||||
|
1. Wizards FOSSBilling + Odoo na VM123
|
||||||
|
2. `install-openpanel.sh` (bare metal)
|
||||||
|
3. `setup-foss-openpanel-module.sh`
|
||||||
|
4. DNS: `financeiro.ligbox.com.br` + `openpanel.ligbox.com.br`
|
||||||
|
5. Traefik CT114 — merge `traefik-routes-snippet.yml`
|
||||||
|
6. DNAT SSH `:2523` pfSense → VM123:22
|
||||||
|
7. Desk Spec 023 — links financeiro
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Canais de cópia
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/ligbox-ops-platform/specs/024-openpanel-fossbilling/
|
||||||
|
/opt/ligbox-ops-platform/docs/anais-referencia/
|
||||||
|
/opt/ligbox-ops-platform/LAPTOP/
|
||||||
|
/root/obsidian-infra/ligbox-ops-platform/
|
||||||
|
```
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
123
ligbox-ops-platform/LAPTOP/INDICE_ANAIS.md
Normal file
123
ligbox-ops-platform/LAPTOP/INDICE_ANAIS.md
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
# Índice — Anais de Referência (Ligbox Ops Platform)
|
||||||
|
|
||||||
|
**Atualizado:** 2026-06-17
|
||||||
|
**Responsável:** Roger / Cursor Agent
|
||||||
|
**VM122:** `10.10.10.122` · SSH WAN `:2522`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Formato
|
||||||
|
|
||||||
|
| Tipo | Extensão | Conteúdo |
|
||||||
|
|------|----------|----------|
|
||||||
|
| **Aspectos** | `*_ASPECTOS.md` | Decisões, arquitectura, ficheiros, comandos, pendências |
|
||||||
|
| **Chat bruto** | `*.txt` | Transcript legível (user + assistant + ferramentas) |
|
||||||
|
| **Chat original** | `*.jsonl` | Transcript Cursor integral |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entradas
|
||||||
|
|
||||||
|
### 2026-06-10 — Spec 013 Migração de E-mail entre Servidores
|
||||||
|
|
||||||
|
| Ficheiro | Descrição |
|
||||||
|
|----------|-----------|
|
||||||
|
| `specs/013-email-server-migration/spec.md` | Spec completa — gate DNS, fases, API |
|
||||||
|
| `specs/013-email-server-migration/research.md` | Ferramentas: imapsync, readpst, imap-upload, TGZ |
|
||||||
|
| `specs/013-email-server-migration/plan.md` | Módulo técnico API + worker |
|
||||||
|
| `specs/013-email-server-migration/infrastructure.md` | VM/recursos — **futuro, não hoje** |
|
||||||
|
| `20260610_SPEC_013_EMAIL_MIGRATION.md` | Cópia spec nos anais |
|
||||||
|
|
||||||
|
**Regra:** migrar e validar **antes** de virar MX/DNS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2026-06-17 — VM123 Finance Stack · FOSS + Odoo + OpenPanel (Spec 024)
|
||||||
|
|
||||||
|
| Ficheiro | Descrição |
|
||||||
|
|----------|-----------|
|
||||||
|
| `specs/024-openpanel-fossbilling/spec.md` | Spec completa — stack financeiro VM123 |
|
||||||
|
| `specs/024-openpanel-fossbilling/tasks.md` | Checklist deploy |
|
||||||
|
| `20260617_VM123_FINANCE_STACK_ASPECTOS.md` | Decisões, domínios ligbox, estado VM123 |
|
||||||
|
| `CREDENCIAIS_LIGBOX_OPS_AMBIENTES_20260617.txt` | Senhas todos os ambientes |
|
||||||
|
| `README_COPIAR_ANAIS_VM123_FINANCE_20260617.txt` | Guia cópia LAPTOP/Obsidian |
|
||||||
|
|
||||||
|
**Domínios (Opção B):**
|
||||||
|
- `financeiro.ligbox.com.br/foss` — FOSSBilling
|
||||||
|
- `financeiro.ligbox.com.br/odoo` — Odoo 16
|
||||||
|
- `openpanel.ligbox.com.br` — OpenPanel
|
||||||
|
|
||||||
|
**Pendente:** wizards FOSS/Odoo, install OpenPanel, Traefik, DNS, DNAT :2523
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2026-06-16 — Serviços MOSP · Orquestração · Purge SSE/Jobs
|
||||||
|
|
||||||
|
| Ficheiro | Descrição |
|
||||||
|
|----------|-----------|
|
||||||
|
| `20260616_SERVICOS_ORQUESTRACAO_PURGE_ASPECTOS.md` | Aspectos completos da sessão |
|
||||||
|
| `chat-bruto/CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.txt` | Chat bruto legível |
|
||||||
|
| `chat-bruto/CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.jsonl` | JSONL original |
|
||||||
|
| `specs/017-vm112-domain-orchestration/spec.md` | Purge domínio VM112 |
|
||||||
|
| `specs/018-service-orchestration/spec.md` | MOSP / Pizza as a Service |
|
||||||
|
|
||||||
|
**Transcript:** `ad3c7400-04ce-47bf-8995-2861d54a831b`
|
||||||
|
|
||||||
|
**Temas:**
|
||||||
|
- Página **Serviços** (tenants de oferta, não na Hero VM112)
|
||||||
|
- Spec 018 MOSP + modelo comercial
|
||||||
|
- Purge drawer timeline + SSE + **jobs async** (fix 504/Failed to fetch)
|
||||||
|
- Purges teste dratcoin/eplacebets
|
||||||
|
- **Pendente VM112 Fase 3** — passos purge em tempo real
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2026-06-10 — Overview + DNS Cloudflare + UI Desk
|
||||||
|
|
||||||
|
| Ficheiro | Descrição |
|
||||||
|
|----------|-----------|
|
||||||
|
| `20260610_OVERVIEW_DNS_UI_ASPECTOS.md` | Aspectos completos da sessão |
|
||||||
|
| `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.txt` | Chat bruto legível |
|
||||||
|
| `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.jsonl` | JSONL original |
|
||||||
|
|
||||||
|
**Transcript:** `161d3d86-8ce8-4a2d-86f7-424b69111cb3`
|
||||||
|
|
||||||
|
**Temas:**
|
||||||
|
- Menu lateral SVG (referência `menu lateral__dashboard.png`)
|
||||||
|
- Overview clássico — cards por tenant, modal domínio
|
||||||
|
- Overview Home estilo Cloudflare (menu novo, original preservado)
|
||||||
|
- API DNS Cloudflare + card na linha Security/Performance/Activity
|
||||||
|
- Fix exibição DNS (fetch independente do scorecard)
|
||||||
|
- Deploy Docker rebuild frontend/api
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entradas anteriores (chat bruto)
|
||||||
|
|
||||||
|
Ver `INDICE_MODELAGEM_BRUTA.txt` em `/root/ligbox-ops-platform-chat-bruto/`:
|
||||||
|
|
||||||
|
- `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_20260604` — visão inicial, arquitectura
|
||||||
|
- `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_VM122_SPEC_20260608` — Spec Kit, webhooks, Wazuh
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Canais espelhados
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/ligbox-ops-platform/docs/anais-referencia/
|
||||||
|
/opt/ligbox-ops-platform/chat-bruto/
|
||||||
|
/root/ligbox-ops-platform-chat-bruto/
|
||||||
|
/root/ligbox-ops-platform-chat-bruto/anais-referencia/
|
||||||
|
/opt/ligbox-ops-platform/LAPTOP/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regenerar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /opt/ligbox-ops-platform/scripts/export-chat-bruto-standalone.py \
|
||||||
|
<caminho.jsonl> CHAT_BRUTO_<NOME>_<YYYYMMDD> <transcript-uuid>
|
||||||
|
```
|
||||||
93
ligbox-ops-platform/LAPTOP/PROVISIONING_CLIENT_CARD.md
Normal file
93
ligbox-ops-platform/LAPTOP/PROVISIONING_CLIENT_CARD.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Spec 024 — Card cliente → FOSS → OpenPanel (provisionamento)
|
||||||
|
|
||||||
|
**Roger · 2026-06-17**
|
||||||
|
|
||||||
|
## O teu raciocínio está correto
|
||||||
|
|
||||||
|
1. **Card do cliente (Desk / portal)** — recolhe dados mínimos do comprador.
|
||||||
|
2. **FOSSBilling** — cria cliente + pedido + activa produto hosting.
|
||||||
|
3. **OpenPanel** — recebe API call do FOSS (`createAccount`) e cria user hosting.
|
||||||
|
4. **pfSense** — **não** cria conta; só encaminha tráfego WAN → Traefik → VM123.
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet → 95.216.14.146 (pfSense WAN)
|
||||||
|
→ NAT :80/:443 → 10.10.10.114 (Traefik)
|
||||||
|
→ financeiro.ligbox.com.br/foss|/odoo → 10.10.10.123
|
||||||
|
→ openpanel.ligbox.com.br → 10.10.10.123:2083
|
||||||
|
```
|
||||||
|
|
||||||
|
**NAT pfSense já existente (não precisa duplicar):**
|
||||||
|
| Regra | WAN | Destino |
|
||||||
|
|-------|-----|---------|
|
||||||
|
| Traefik HTTP | 80 | 10.10.10.114 |
|
||||||
|
| Traefik HTTPS | 443 | 10.10.10.114 |
|
||||||
|
|
||||||
|
Novos hostnames só precisam de **DNS Cloudflare** → mesmo IP público.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Campos obrigatórios no card (→ FOSS → OpenPanel)
|
||||||
|
|
||||||
|
| Campo no card | Vai para FOSSBilling | Vai para OpenPanel API | Notas |
|
||||||
|
|---------------|----------------------|------------------------|-------|
|
||||||
|
| **email** | Cliente `email` | `email` | Login/recuperação |
|
||||||
|
| **nome / empresa** | Cliente `first_name` / company | — | Facturação |
|
||||||
|
| **domínio** | Opcional no produto | gera `username` (7 chars + dígito) | ex: `cliente1.com` → user `cliente1x` |
|
||||||
|
| **senha painel** | Order / hosting password | `password` | Senha OpenPanel user |
|
||||||
|
| **plano** | Product / `plan_name` | `plan_name` | **Deve coincidir** com plano OpenPanel |
|
||||||
|
| **CPF/CNPJ** | Cliente custom field | — | Fiscal (Odoo fase 2) |
|
||||||
|
| **telefone** | Cliente `phone` | — | Suporte |
|
||||||
|
|
||||||
|
### Plano OpenPanel criado (VM123)
|
||||||
|
|
||||||
|
| name | id | Uso |
|
||||||
|
|------|-----|-----|
|
||||||
|
| `ligbox-site-cms` | 3 | Site/CMS Spec 018 |
|
||||||
|
| `Standard plan` | 1 | Testes |
|
||||||
|
| `Developer Plus` | 2 | Maior |
|
||||||
|
|
||||||
|
**FOSS product** deve usar `plan_name` = `ligbox-site-cms` (exacto).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config FOSSBilling → Server OpenPanel
|
||||||
|
|
||||||
|
Admin FOSS → **System → Hosting plans → New server**
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|-------|-------|
|
||||||
|
| Manager | OpenPanel |
|
||||||
|
| Hostname | `10.10.10.123` |
|
||||||
|
| Port | `2087` |
|
||||||
|
| Secure | Yes (HTTPS) |
|
||||||
|
| Username | `ligboxadmin` |
|
||||||
|
| Password | `LbOpen805353` |
|
||||||
|
|
||||||
|
Test connection → depois associar produto hosting ao server + plano `ligbox-site-cms`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxo automático (pedido pago)
|
||||||
|
|
||||||
|
```
|
||||||
|
Card cliente (email, domínio, plano, senha)
|
||||||
|
→ FOSSBilling: create client + order
|
||||||
|
→ FOSSBilling: activate hosting
|
||||||
|
→ OpenPanel.php: POST /api/users
|
||||||
|
{ email, username, password, plan_name }
|
||||||
|
→ OpenPanel: conta hosting criada
|
||||||
|
→ Email cliente com URL openpanel.ligbox.com.br
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O que o Desk precisa (Spec 023 fase 2)
|
||||||
|
|
||||||
|
No card **Serviços / Site CMS**:
|
||||||
|
- `client_email` *
|
||||||
|
- `client_name` *
|
||||||
|
- `domain` * (para username OpenPanel)
|
||||||
|
- `hosting_plan` * (dropdown: ligbox-site-cms)
|
||||||
|
- `panel_password` * (ou gerar)
|
||||||
|
- `foss_client_id` (após sync)
|
||||||
|
- `openpanel_username` (read-only após provision)
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
ANAIS DE REFERÊNCIA — copiar para C:\LAPTOP no Windows
|
||||||
|
========================================================
|
||||||
|
Data: 2026-06-10
|
||||||
|
Transcript: 161d3d86-8ce8-4a2d-86f7-424b69111cb3
|
||||||
|
Sessão: Overview + DNS Cloudflare + UI Desk
|
||||||
|
|
||||||
|
Host VM122: 95.216.14.146 · Porta SSH: 2522
|
||||||
|
Pasta servidor: /root/ligbox-ops-platform-chat-bruto/
|
||||||
|
|
||||||
|
Ficheiros principais:
|
||||||
|
anais-referencia/20260610_OVERVIEW_DNS_UI_ASPECTOS.md
|
||||||
|
anais-referencia/INDICE_ANAIS.md
|
||||||
|
CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.txt
|
||||||
|
CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.jsonl
|
||||||
|
INDICE_MODELAGEM_BRUTA.txt
|
||||||
|
|
||||||
|
Copiar para C:\LAPTOP (PowerShell):
|
||||||
|
mkdir C:\LAPTOP\projetos\ligbox-ops-platform-anais 2>$null
|
||||||
|
scp -P 2522 -r root@95.216.14.146:/root/ligbox-ops-platform-chat-bruto/anais-referencia C:\LAPTOP\projetos\ligbox-ops-platform-anais\
|
||||||
|
scp -P 2522 root@95.216.14.146:/root/ligbox-ops-platform-chat-bruto/CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.txt C:\LAPTOP\projetos\ligbox-ops-platform-anais\
|
||||||
|
scp -P 2522 root@95.216.14.146:/root/ligbox-ops-platform-chat-bruto/CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.jsonl C:\LAPTOP\projetos\ligbox-ops-platform-anais\
|
||||||
|
scp -P 2522 root@95.216.14.146:/root/ligbox-ops-platform-chat-bruto/INDICE_MODELAGEM_BRUTA.txt C:\LAPTOP\projetos\ligbox-ops-platform-anais\
|
||||||
|
|
||||||
|
Alternativa projeto:
|
||||||
|
scp -P 2522 -r root@95.216.14.146:/opt/ligbox-ops-platform/docs/anais-referencia C:\LAPTOP\projetos\ligbox-ops-platform-anais\
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# README — Copiar para LAPTOP / Obsidian (2026-06-16)
|
||||||
|
|
||||||
|
**Sessão:** Serviços MOSP · Orquestração VM122 · Purge SSE/Jobs
|
||||||
|
**Roger**
|
||||||
|
|
||||||
|
## Ficheiros desta sessão
|
||||||
|
|
||||||
|
| Ficheiro | Descrição |
|
||||||
|
|----------|-----------|
|
||||||
|
| `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.txt` | Chat integral legível |
|
||||||
|
| `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.jsonl` | Transcript Cursor original |
|
||||||
|
| `20260616_SERVICOS_ORQUESTRACAO_PURGE_ASPECTOS.md` | Decisões, APIs, fixes, próximos passos VM112 |
|
||||||
|
|
||||||
|
## Onde estão (VM122)
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/ligbox-ops-platform/chat-bruto/
|
||||||
|
/opt/ligbox-ops-platform/docs/anais-referencia/
|
||||||
|
/opt/ligbox-ops-platform/LAPTOP/
|
||||||
|
/root/ligbox-ops-platform-chat-bruto/
|
||||||
|
/root/obsidian-infra/ligbox-ops-platform/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transcript Cursor
|
||||||
|
|
||||||
|
`ad3c7400-04ce-47bf-8995-2861d54a831b`
|
||||||
|
|
||||||
|
## Continuar na VM112
|
||||||
|
|
||||||
|
1. Ler `20260616_*_ASPECTOS.md` secção 9 (Fase 3 purge passo a passo)
|
||||||
|
2. Path: `/opt/ligbox-wizard/backend/app/services/domain_orchestration.py`
|
||||||
|
3. Specs: `017`, `018` em `/opt/ligbox-ops-platform/specs/`
|
||||||
|
|
||||||
|
## Regenerar chat bruto
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /opt/ligbox-ops-platform/scripts/export-chat-bruto-standalone.py \
|
||||||
|
/root/.cursor/projects/1781626937265/agent-transcripts/ad3c7400-04ce-47bf-8995-2861d54a831b/ad3c7400-04ce-47bf-8995-2861d54a831b.jsonl \
|
||||||
|
CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616 \
|
||||||
|
ad3c7400-04ce-47bf-8995-2861d54a831b
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
# README — Copiar para LAPTOP / Obsidian (2026-06-17)
|
||||||
|
|
||||||
|
**Sessão:** VM123 Finance Stack · Spec 024 · Domínios ligbox · Credenciais
|
||||||
|
**Roger**
|
||||||
|
|
||||||
|
## Ficheiros desta sessão
|
||||||
|
|
||||||
|
| Ficheiro | Descrição |
|
||||||
|
|----------|-----------|
|
||||||
|
| `specs/024-openpanel-fossbilling/spec.md` | Spec FOSS + Odoo + OpenPanel |
|
||||||
|
| `specs/024-openpanel-fossbilling/tasks.md` | Checklist deploy |
|
||||||
|
| `20260617_VM123_FINANCE_STACK_ASPECTOS.md` | Decisões, estado, arquitectura |
|
||||||
|
| `CREDENCIAIS_LIGBOX_OPS_AMBIENTES_20260617.txt` | Senhas todos os ambientes |
|
||||||
|
|
||||||
|
## Onde estão (VM122)
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/ligbox-ops-platform/specs/024-openpanel-fossbilling/
|
||||||
|
/opt/ligbox-ops-platform/docs/anais-referencia/
|
||||||
|
/opt/ligbox-ops-platform/LAPTOP/
|
||||||
|
/root/obsidian-infra/ligbox-ops-platform/
|
||||||
|
```
|
||||||
|
|
||||||
|
## URLs finais (Opção B)
|
||||||
|
|
||||||
|
- FOSSBilling: https://financeiro.ligbox.com.br/foss
|
||||||
|
- Odoo 16: https://financeiro.ligbox.com.br/odoo
|
||||||
|
- OpenPanel: https://openpanel.ligbox.com.br
|
||||||
|
|
||||||
|
## Continuar deploy VM123
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@10.10.10.123
|
||||||
|
cd /opt/vm123-finance-stack
|
||||||
|
# Wizard FOSS: http://10.10.10.123:8092
|
||||||
|
# Wizard Odoo: http://10.10.10.123:8069
|
||||||
|
bash install-openpanel.sh
|
||||||
|
bash setup-foss-openpanel-module.sh
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
# Anais de Referência — Overview, DNS Cloudflare e UI Desk
|
||||||
|
|
||||||
|
**Data:** 2026-06-10
|
||||||
|
**Utilizador:** Roger
|
||||||
|
**Transcript Cursor:** `161d3d86-8ce8-4a2d-86f7-424b69111cb3`
|
||||||
|
**Projeto:** Ligbox Ops Platform · VM122 (`10.10.10.122:8080`)
|
||||||
|
**Chat bruto:** `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.{txt,jsonl}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Resumo executivo
|
||||||
|
|
||||||
|
Sessão focada em **UI/UX do Support Desk** e **Audit Overview**:
|
||||||
|
|
||||||
|
1. Menu lateral redesenhado com **ícones SVG inline** (não imagens recortadas).
|
||||||
|
2. **Overview clássico** mantido — cards = **tenants** (não empresas individuais).
|
||||||
|
3. Modal de detalhe por tenant com lista de domínios, timeline, checks, IP de acesso.
|
||||||
|
4. Novo menu **Overview Home** (estilo Cloudflare) — **sem apagar** o Overview original.
|
||||||
|
5. Card **Apontamentos DNS (Cloudflare)** via API — integrado na **linha de métricas** (Security · Performance · Activity · DNS).
|
||||||
|
6. Correção de bug: DNS descartado quando scorecard falhava (`Promise.all`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Infraestrutura e deploy
|
||||||
|
|
||||||
|
| Item | Valor |
|
||||||
|
|------|-------|
|
||||||
|
| Host | VM122 `ligbox-ops` |
|
||||||
|
| URL Desk | `http://10.10.10.122:8080` |
|
||||||
|
| Compose | `docker-compose.mvp.yml` |
|
||||||
|
| Frontend | container `ligbox-ops-platform_frontend_1` (nginx) |
|
||||||
|
| API | container `ligbox-ops-platform_api_1` |
|
||||||
|
| Código ativo | `./frontend/` e `./api/` (não a raiz `/opt/ligbox-ops-platform/index.html`) |
|
||||||
|
|
||||||
|
**Rebuild obrigatório após alterações frontend/API:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/ligbox-ops-platform
|
||||||
|
docker-compose -f docker-compose.mvp.yml up -d --build frontend
|
||||||
|
# Se API mudou:
|
||||||
|
docker-compose -f docker-compose.mvp.yml up -d --build api frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cache bust:** `index.html` usa query `?v=20260610dns3` em `styles.css` e `app.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Menu lateral
|
||||||
|
|
||||||
|
### Pedido Roger
|
||||||
|
- Referência visual: `frontend/menu lateral__dashboard.png`
|
||||||
|
- Ícones **separados**, construídos como elementos (SVG), não PNG recortado.
|
||||||
|
- Espaçamento vertical **compacto** sem reduzir tamanho dos ícones.
|
||||||
|
- Modelo premium dos ícones: aceitável “por hora”.
|
||||||
|
|
||||||
|
### Implementação
|
||||||
|
- SVG symbols em `frontend/index.html` (`#icon-dashboard`, `#icon-overview`, etc.)
|
||||||
|
- CSS em `frontend/assets/styles.css` (`.nav-icon-wrap`, `.nav-icon-svg`)
|
||||||
|
- Variáveis: `--sidebar-w`, `--nav-icon-col`, `min-height` dos botões reduzido progressivamente
|
||||||
|
|
||||||
|
### Ficheiros
|
||||||
|
- `frontend/index.html`
|
||||||
|
- `frontend/assets/styles.css`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Overview clássico (mantido)
|
||||||
|
|
||||||
|
### Modelo de dados
|
||||||
|
- **1 card = 1 tenant** (ex.: VM112, VM104)
|
||||||
|
- 25 empresas em onboarding no mesmo tenant → **1 card** com domínios agregados
|
||||||
|
- Resposta esperada Roger: 2 cards para tenants distintos, não 25 cards
|
||||||
|
|
||||||
|
### Modal tenant (`openOverviewModal`)
|
||||||
|
- Endpoint: `GET /api/v1/audit/tenants/{id}/details`
|
||||||
|
- Lista domínios clicáveis
|
||||||
|
- Resumo: total, em execução, concluídos, falharam, com erros
|
||||||
|
|
||||||
|
### Modal domínio (`openOverviewDomainDetail`)
|
||||||
|
- Scorecard: `GET /api/v1/audit/tenants/{id}/scorecard?domain=...`
|
||||||
|
- Timeline webhook com `client_ip`, email, timestamps
|
||||||
|
- Checks de auditoria
|
||||||
|
- Ticket associado (abrir em Tickets)
|
||||||
|
- **DNS Cloudflare** (secção dedicada)
|
||||||
|
|
||||||
|
### Ficheiros backend
|
||||||
|
- `api/app/audit_store.py` — `tenant_details()`, scorecard
|
||||||
|
- `api/app/main.py` — rotas audit + webhook grava `client_ip` / `ingress_client_ip`
|
||||||
|
|
||||||
|
### Ficheiros frontend
|
||||||
|
- `frontend/assets/app.js` — `openOverviewModal`, `renderOverviewModalList`, `openOverviewDomainDetail`
|
||||||
|
- `frontend/index.html` — `#overview-modal`
|
||||||
|
- `frontend/assets/styles.css` — `.overview-domain-row`, `.modal-panel-lg`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Overview Home (novo — Cloudflare-style)
|
||||||
|
|
||||||
|
### Menu
|
||||||
|
- Item **Overview Home** com badge `novo`
|
||||||
|
- View: `overview-home` (`#view-overview-home`)
|
||||||
|
- Overview original **intacto** em `overview`
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
- Toolbar período: 24h / 7d / 30d
|
||||||
|
- **Linha de métricas (4 cards):**
|
||||||
|
1. Security — domínios com alertas, eventos Wazuh
|
||||||
|
2. Performance — checks pass %, degraded/critical
|
||||||
|
3. Activity — onboarding em execução, webhooks
|
||||||
|
4. **Apontamentos DNS (Cloudflare)** — card interativo
|
||||||
|
- Painéis: Domains, Audit trail, Infra nodes, Next steps
|
||||||
|
|
||||||
|
### Interação DNS na linha de processos
|
||||||
|
- Clique em domínio (Domains ou Audit trail) → carrega DNS no **4.º card**
|
||||||
|
- Funções: `showOverviewHomeDnsPanel()`, `htmlCloudflareDnsCardInline()`
|
||||||
|
- Estado: `state.overviewHomeDnsDomain`
|
||||||
|
|
||||||
|
### Ficheiros
|
||||||
|
- `frontend/assets/app.js` — `renderOverviewHome()`, `buildOverviewHomeTrail()`
|
||||||
|
- `frontend/assets/styles.css` — `.cf-home*`, `.cf-metrics-row` (4 colunas), `.cf-dns-metric-card`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. DNS Cloudflare
|
||||||
|
|
||||||
|
### API backend
|
||||||
|
- Módulo: `api/app/cloudflare_dns.py`
|
||||||
|
- Função: `fetch_domain_dns(domain, email_service=...)`
|
||||||
|
- Endpoint: `GET /api/v1/dns/cloudflare/records?domain=...&email_service=true|false`
|
||||||
|
- Permissão: `can_read_cloudflare_dns` em `api/app/permissions.py`
|
||||||
|
- Roles: `super_admin`, `ops_lead`, `technician`, `noc`
|
||||||
|
|
||||||
|
### Tokens (`.env`)
|
||||||
|
```
|
||||||
|
CLOUDFLARE_API_TOKENS=EYH0ZbKTI41f1O0EoW5uxGUUCA3-Fsrt6b4-1xYJ,UBvRO4URpoGPH-vgjVRfKWOpklvmD9vV9PRX43mP
|
||||||
|
```
|
||||||
|
|
||||||
|
### Classificação de registos
|
||||||
|
- `mx`, `spf`, `dkim`, `dmarc`, `mail-host`, `autodiscover`, `mail-alias`, `other`
|
||||||
|
- Filtro por domínio na zona pai Cloudflare
|
||||||
|
- `email_service=true` quando tenant_id=1 ou etapa funil de e-mail
|
||||||
|
|
||||||
|
### Testes validados (API via nginx :8080)
|
||||||
|
| Domínio | Resultado |
|
||||||
|
|---------|-----------|
|
||||||
|
| `itecnologys.com` | 56 registos |
|
||||||
|
| `ligbox.com.br` | 18 registos |
|
||||||
|
| `diarissima.com` | Zona não encontrada (sem token/zona) |
|
||||||
|
|
||||||
|
### Bug corrigido (2026-06-10)
|
||||||
|
- **Antes:** `Promise.all([scorecard, dns])` — falha do scorecard descartava DNS
|
||||||
|
- **Depois:** `fetchCloudflareDns()` independente; sempre exibe card (dados ou erro)
|
||||||
|
|
||||||
|
### Teste rápido
|
||||||
|
```bash
|
||||||
|
TOKEN=$(curl -s -X POST "http://10.10.10.122:8080/api/v1/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"root","password":"<senha>"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
|
||||||
|
|
||||||
|
curl -s "http://10.10.10.122:8080/api/v1/dns/cloudflare/records?domain=itecnologys.com&email_service=true" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool | head -30
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Mapa de ficheiros alterados (sessão)
|
||||||
|
|
||||||
|
| Área | Ficheiro | Alteração |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| Frontend | `frontend/index.html` | SVG menu, modal overview, view overview-home, cache bust |
|
||||||
|
| Frontend | `frontend/assets/app.js` | Menu, overview modal, overview home, DNS fetch/card |
|
||||||
|
| Frontend | `frontend/assets/styles.css` | Sidebar, cf-home, DNS tables, metrics 4-col |
|
||||||
|
| API | `api/app/main.py` | Rota DNS Cloudflare, webhook IP |
|
||||||
|
| API | `api/app/cloudflare_dns.py` | **Novo** — integração Cloudflare |
|
||||||
|
| API | `api/app/audit_store.py` | tenant details, scorecard |
|
||||||
|
| API | `api/app/permissions.py` | `can_read_cloudflare_dns` |
|
||||||
|
| Config | `.env` | `CLOUDFLARE_API_TOKENS` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Decisões e pendências
|
||||||
|
|
||||||
|
| Tema | Estado |
|
||||||
|
|------|--------|
|
||||||
|
| Overview vs Overview Home | Roger a decidir qual manter |
|
||||||
|
| Ícones premium (referência PNG) | Aceitável por hora; melhorar depois |
|
||||||
|
| Domínios sem zona CF | Adicionar tokens/zones (`diarissima.com`, `*.ligbox`) |
|
||||||
|
| AUD collectors DNS | Parcial no backlog |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Pedidos Roger (cronologia)
|
||||||
|
|
||||||
|
1. Menu lateral — ícones recortados/pequenos → SVG separado
|
||||||
|
2. “Não mudou nada” → rebuild Docker
|
||||||
|
3. Compactar espaço vertical do menu
|
||||||
|
4. Cards Overview = tenants?
|
||||||
|
5. 25 empresas → quantos cards? → 1 por tenant
|
||||||
|
6. Modal com domínios, timestamps, erros, IP
|
||||||
|
7. Tela estilo Cloudflare para Audit → Overview Home
|
||||||
|
8. Criar Overview Home sem destruir atual
|
||||||
|
9. Card DNS Cloudflare para gestão de domínio/e-mail
|
||||||
|
10. “Não está exibindo” → fix Promise.all + painel visível
|
||||||
|
11. Colocar card DNS na linha Security/Performance/Activity
|
||||||
|
12. **Salvar aspectos + chat bruto nos anais de referência** (este documento)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Canais de arquivo (chat bruto + anais)
|
||||||
|
|
||||||
|
| Canal | Caminho |
|
||||||
|
|-------|---------|
|
||||||
|
| VM122 principal | `/root/ligbox-ops-platform-chat-bruto/` |
|
||||||
|
| Anais VM122 | `/root/ligbox-ops-platform-chat-bruto/anais-referencia/` |
|
||||||
|
| Projeto | `/opt/ligbox-ops-platform/docs/anais-referencia/` |
|
||||||
|
| Chat bruto projeto | `/opt/ligbox-ops-platform/chat-bruto/` |
|
||||||
|
| LAPTOP (staging scp) | `/opt/ligbox-ops-platform/LAPTOP/` |
|
||||||
|
| Obsidian VM112 | `/root/obsidian-infra/ligbox-ops-platform/` (se existir) |
|
||||||
|
|
||||||
|
**Regenerar chat bruto:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /opt/ligbox-ops-platform/scripts/export-chat-bruto-standalone.py \
|
||||||
|
/root/.cursor/projects/1781094241105/agent-transcripts/161d3d86-8ce8-4a2d-86f7-424b69111cb3/161d3d86-8ce8-4a2d-86f7-424b69111cb3.jsonl \
|
||||||
|
CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610 \
|
||||||
|
161d3d86-8ce8-4a2d-86f7-424b69111cb3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Documento gerado automaticamente na sessão Cursor — Ligbox Ops Platform.*
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
# Anais de Referência — Serviços MOSP, Orquestração VM122, Purge SSE/Jobs
|
||||||
|
|
||||||
|
**Data:** 2026-06-16
|
||||||
|
**Utilizador:** Roger
|
||||||
|
**Transcript Cursor:** `ad3c7400-04ce-47bf-8995-2861d54a831b`
|
||||||
|
**Projeto:** Ligbox Ops Platform · Desk VM122 (`10.10.10.122:8091`) + Wizard VM112 (`10.10.10.112`)
|
||||||
|
**Chat bruto:** `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.{txt,jsonl}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Resumo executivo
|
||||||
|
|
||||||
|
Sessão focada em **orquestração MOSP no Desk** (não no wizard VM112):
|
||||||
|
|
||||||
|
1. Página **Serviços** (ex-Contas/Overview Home) — clientes + tenants de oferta + purge Spec 017.
|
||||||
|
2. Spec **018** — modelo Pizza as a Service / MOSP / catálogo multi-produto.
|
||||||
|
3. Purge com painel lateral timeline + **SSE** + **jobs async/polling** (fix 504 / Failed to fetch).
|
||||||
|
4. Purges testados: `dratcoin.com`, `eplacebets.com` — UI falhou mas backend concluiu.
|
||||||
|
5. **Fase 3 pendente VM112** — passos Carbonio/CF/Traefik em tempo real dentro do purge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Specs criadas/actualizadas
|
||||||
|
|
||||||
|
| Spec | Path | Estado |
|
||||||
|
|------|------|--------|
|
||||||
|
| 017 | `specs/017-vm112-domain-orchestration/spec.md` | Purge domínio — Fase 1 concluída |
|
||||||
|
| 018 | `specs/018-service-orchestration/spec.md` | MOSP, Pizza as a Service, Fase 1 UI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UI Desk — menu Serviços
|
||||||
|
|
||||||
|
| Item | Valor |
|
||||||
|
|------|-------|
|
||||||
|
| Módulo ID | `overview-home` (sem breaking change) |
|
||||||
|
| Menu | **Serviços** |
|
||||||
|
| Título | Orquestração de Serviços |
|
||||||
|
| Subtítulo | Desk VM122 · Orquestração MOSP |
|
||||||
|
| Layout | 3 colunas: Clientes · Tenants de Oferta · Escopo OPS |
|
||||||
|
|
||||||
|
**Ficheiros principais:**
|
||||||
|
- `frontend/assets/accounts.js` → `DeskServices`
|
||||||
|
- `frontend/assets/styles.css` → `.servicos-*`, `.vm112-purge-drawer`
|
||||||
|
- `frontend/index.html` → modal + drawer purge
|
||||||
|
- `api/app/modules/registry.py`
|
||||||
|
|
||||||
|
**Regra:** cada oferta MOSP terá **wizard próprio**; VM112 Hero = só e-mail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API Desk — domínios VM112
|
||||||
|
|
||||||
|
| Método | Path | Uso |
|
||||||
|
|--------|------|-----|
|
||||||
|
| GET | `/api/v1/vm112/domains` | Lista clientes Fase 1 |
|
||||||
|
| GET | `/api/v1/vm112/domains/{domain}` | Detalhe modal |
|
||||||
|
| POST | `/api/v1/vm112/domains/{domain}/purge` | Purge síncrono (legado) |
|
||||||
|
| POST | `/api/v1/vm112/domains/{domain}/purge/stream` | SSE timeline |
|
||||||
|
| POST | `/api/v1/vm112/domains/{domain}/purge/jobs` | **Recomendado** — job async |
|
||||||
|
| GET | `/api/v1/vm112/purge/jobs/{job_id}` | Poll timeline 2s |
|
||||||
|
|
||||||
|
**Ficheiros API:**
|
||||||
|
- `api/app/vm112_domains.py`
|
||||||
|
- `api/app/vm112_domains_routes.py`
|
||||||
|
- `api/app/vm112_purge_stream.py`
|
||||||
|
- `api/app/vm112_purge_jobs.py`
|
||||||
|
|
||||||
|
**RBAC:** `super_admin`, `ops_lead` + senha Root no purge.
|
||||||
|
**Blocklist:** `ligbox.com.br`, `itecnologys.com`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Purge — incidentes e fixes
|
||||||
|
|
||||||
|
### 504 Gateway Timeout (~60s)
|
||||||
|
- **Causa:** nginx proxy timeout 60s; purge VM112 demora minutos.
|
||||||
|
- **Fix:** `frontend/nginx.conf` → `proxy_read_timeout 600s`, `proxy_buffering off`.
|
||||||
|
|
||||||
|
### Failed to fetch (~79s) via `desk.ligbox.com.br`
|
||||||
|
- **Causa:** Traefik/SSE ligação longa cortada; browser perde stream.
|
||||||
|
- **Fix:** purge **async jobs + polling** (pedidos curtos GET a cada 2s).
|
||||||
|
- **Nota:** purge **concluiu** mesmo com erro UI (`dratcoin`, `eplacebets` sumiram da lista).
|
||||||
|
|
||||||
|
### Poll automático página Serviços (piscava)
|
||||||
|
- **Causa:** `refresh()` 30s re-renderizava com «A carregar…»
|
||||||
|
- **Fix:** poll silencioso em `renderPage({ poll: true })`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Domínios VM112 (fim de sessão)
|
||||||
|
|
||||||
|
Após purges teste, lista típica:
|
||||||
|
- `betinsport.com`, `diarissima.com`, `ibytera.com`, `itecnologys.com`, `myvexx.com`
|
||||||
|
- Removidos: `dratcoin.com`, `eplacebets.com` (testes purge)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/ligbox-ops-platform
|
||||||
|
docker-compose -f docker-compose.mvp.yml build api frontend
|
||||||
|
docker-compose -f docker-compose.mvp.yml up -d api frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
**URLs:**
|
||||||
|
- Desk: `http://10.10.10.122:8091` / `https://desk.ligbox.com.br`
|
||||||
|
- API: `http://10.10.10.122:8080`
|
||||||
|
- Wizard: `https://onboard.ligbox.com.br` (VM112)
|
||||||
|
|
||||||
|
**Hard refresh:** Ctrl+Shift+R após deploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Reteste E2E wizard e-mail
|
||||||
|
|
||||||
|
1. Desk → Serviços → purge domínio teste (se existir)
|
||||||
|
2. Portal onboard → Self-Service → `/onboard`
|
||||||
|
3. Domínio → DNS → conta → infra
|
||||||
|
4. Desk → Serviços → Actualizar → cliente reaparece
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Próximo passo — VM112 (Fase 3)
|
||||||
|
|
||||||
|
**Não implementado nesta sessão** (sem SSH VM112):
|
||||||
|
|
||||||
|
- `domain_orchestration.py` — purge passo a passo com eventos
|
||||||
|
- `POST /api/admin/domains/{domain}/purge/jobs` na VM112
|
||||||
|
- Desk proxy eventos VM112 para drawer timeline
|
||||||
|
|
||||||
|
**Path produção VM112:** `/opt/ligbox-wizard`
|
||||||
|
**SSH:** `root@10.10.10.112` (credencial user rule: `@betinplace`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Canais de arquivo
|
||||||
|
|
||||||
|
| Canal | Path |
|
||||||
|
|-------|------|
|
||||||
|
| Anais VM122 | `/opt/ligbox-ops-platform/docs/anais-referencia/` |
|
||||||
|
| Chat bruto projeto | `/opt/ligbox-ops-platform/chat-bruto/` |
|
||||||
|
| Chat bruto central | `/root/ligbox-ops-platform-chat-bruto/` |
|
||||||
|
| Obsidian | `/root/obsidian-infra/ligbox-ops-platform/` |
|
||||||
|
| LAPTOP | `/opt/ligbox-ops-platform/LAPTOP/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Decisões Roger (registo)
|
||||||
|
|
||||||
|
- MOSP planeado no **Desk VM122**, não na Hero VM112.
|
||||||
|
- Cada oferta = wizard próprio (Proxmox, servidor físico, etc.).
|
||||||
|
- Modelo comercial Pizza as a Service documentado na Spec 018.
|
||||||
|
- Purge Spec 017 mantido; UI evolui (drawer + jobs).
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
# Anais de Referência — VM123 Finance Stack · FOSS + Odoo + OpenPanel (Spec 024)
|
||||||
|
|
||||||
|
**Data:** 2026-06-17
|
||||||
|
**Utilizador:** Roger
|
||||||
|
**Projeto:** Ligbox Ops Platform · VM123 (`10.10.10.123`)
|
||||||
|
**Spec:** `specs/024-openpanel-fossbilling/`
|
||||||
|
**Deploy:** `deploy/vm123-finance-stack/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisões desta sessão
|
||||||
|
|
||||||
|
| Tema | Decisão |
|
||||||
|
|------|---------|
|
||||||
|
| Stack | FOSSBilling + Odoo 16 (Docker) + OpenPanel (bare metal) |
|
||||||
|
| Domínios | **Opção B** — marca `ligbox.com.br` |
|
||||||
|
| FOSSBilling | `https://financeiro.ligbox.com.br/foss` |
|
||||||
|
| Odoo 16 | `https://financeiro.ligbox.com.br/odoo` |
|
||||||
|
| OpenPanel | `https://openpanel.ligbox.com.br` (subdomínio dedicado) |
|
||||||
|
| Integração | FOSS → OpenPanel via API :2087 (módulo GitHub) |
|
||||||
|
| Odoo | ERP interno — sync com FOSS/OpenPanel = fase 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estado VM123 (2026-06-17)
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| VM Proxmox 123 `vm123-finance` | ✅ running |
|
||||||
|
| IP / GW | `10.10.10.123/24` · gw `10.10.10.1` |
|
||||||
|
| SSH LAN | ✅ `root@10.10.10.123` |
|
||||||
|
| Bootstrap (swap, fail2ban, UFW) | ✅ |
|
||||||
|
| DNS fix pós-clone | ✅ `resolv.conf` estático `1.1.1.8` |
|
||||||
|
| Docker FOSS + Odoo | ✅ ports `:8092` `:8069` |
|
||||||
|
| Wizard FOSS / Odoo | ⏳ pendente |
|
||||||
|
| OpenPanel install | ⏳ pendente |
|
||||||
|
| Traefik CT114 rotas | ⏳ pendente confirmação Roger |
|
||||||
|
| DNS Cloudflare | ⏳ pendente |
|
||||||
|
| DNAT SSH WAN `:2523` | ⏳ pendente pfSense |
|
||||||
|
|
||||||
|
**OS:** Debian 13 (clone VM121) · **RAM:** 4 GB + swap 2 GB · **Disco:** ~60 GB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```
|
||||||
|
Traefik CT114
|
||||||
|
│
|
||||||
|
├── financeiro.ligbox.com.br/foss → VM123:8092 (FOSSBilling)
|
||||||
|
├── financeiro.ligbox.com.br/odoo → VM123:8069 (Odoo 16)
|
||||||
|
└── openpanel.ligbox.com.br → VM123:2083 (OpenPanel host)
|
||||||
|
|
||||||
|
VM123 Docker: fossbilling + mariadb + odoo + postgres
|
||||||
|
VM123 host: OpenPanel Enterprise (NÃO Docker)
|
||||||
|
FOSSBilling ──API :2087──► OpenPanel (provisionar hosting)
|
||||||
|
Desk VM122 ──links──► financeiro.ligbox.com.br/foss
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credenciais
|
||||||
|
|
||||||
|
Ficheiro dedicado (mesmo conteúdo):
|
||||||
|
`CREDENCIAIS_LIGBOX_OPS_AMBIENTES_20260617.txt`
|
||||||
|
|
||||||
|
### Desk VM122 — `desk.ligbox.com.br`
|
||||||
|
|
||||||
|
| User | Senha | Papel |
|
||||||
|
|------|-------|-------|
|
||||||
|
| root | `gsq9qtIUD6SQ45Egm8yP` | super_admin |
|
||||||
|
| admin | `gsq9qtIUD6SQ45Egm8yP` | ops_lead |
|
||||||
|
| mini | `gsq9qtIUD6SQ45Egm8yP` | technician |
|
||||||
|
| noc | `gsq9qtIUD6SQ45Egm8yP` | noc |
|
||||||
|
|
||||||
|
SSH Linux VM122: `root` / `805353`
|
||||||
|
**Nota:** `805353` não funciona no login Desk (rotacionada 2026-06-10).
|
||||||
|
|
||||||
|
### VM123 Finance — `10.10.10.123`
|
||||||
|
|
||||||
|
| User | Senha | Uso |
|
||||||
|
|------|-------|-----|
|
||||||
|
| root | `805353` | SSH |
|
||||||
|
| admin | `805353` | sudo |
|
||||||
|
| mini | `805353` | automação |
|
||||||
|
|
||||||
|
**Docker `.env`** (`/opt/vm123-finance-stack/.env`):
|
||||||
|
|
||||||
|
| Variável | Valor |
|
||||||
|
|----------|-------|
|
||||||
|
| FOSS_MARIADB_PASSWORD | `LbFoss9367c416` |
|
||||||
|
| ODOO_DB_PASSWORD | `LbOdood9ca25c3` |
|
||||||
|
| FOSSBILLING_URL | `https://financeiro.ligbox.com.br/foss` |
|
||||||
|
| ODOO_URL | `https://financeiro.ligbox.com.br/odoo` |
|
||||||
|
| OPENPANEL_DOMAIN | `openpanel.ligbox.com.br` |
|
||||||
|
|
||||||
|
FOSSBilling admin / Odoo master: **ainda não configurados** (wizards).
|
||||||
|
|
||||||
|
### Wizard VM112 — `10.10.10.112`
|
||||||
|
|
||||||
|
| User | Senha |
|
||||||
|
|------|-------|
|
||||||
|
| root SSH | `@betinplace` |
|
||||||
|
|
||||||
|
API admin key: `ibytera-corp-api-key-change-later`
|
||||||
|
|
||||||
|
### Traefik CT114 — `10.10.10.114`
|
||||||
|
|
||||||
|
| User | Senha |
|
||||||
|
|------|-------|
|
||||||
|
| root SSH | `805353` |
|
||||||
|
|
||||||
|
### Proxmox — `10.10.10.2:8006`
|
||||||
|
|
||||||
|
| User | Senha |
|
||||||
|
|------|-------|
|
||||||
|
| root@pam | `@betinplace` |
|
||||||
|
|
||||||
|
SSH host: fechado · API: OK
|
||||||
|
|
||||||
|
### pfSense API
|
||||||
|
|
||||||
|
| User | Senha |
|
||||||
|
|------|-------|
|
||||||
|
| api_cursor | `805353` |
|
||||||
|
| user_api | `@betinplace` |
|
||||||
|
|
||||||
|
URL: `https://firewall.itecnologys.com/api/v2/`
|
||||||
|
API Key: `7015072cb259165a3ac4b304f556d035`
|
||||||
|
|
||||||
|
### Tokens internos Desk (`.env` VM122)
|
||||||
|
|
||||||
|
| Item | Valor |
|
||||||
|
|------|-------|
|
||||||
|
| JWT_SECRET | `e4b303fe43f8b24b1d924f5ab235d2cea3657b6cd132c925ce60280c64c87ade` |
|
||||||
|
| OPS_INTERNAL_TOKEN | `128b96e7c12d9b391edbc727880fbdc905b60fa59b52a865` |
|
||||||
|
| WEBHOOK_SECRET | `ligbox-ops-dev-secret` |
|
||||||
|
| VM112_ASSIST_TOKEN | `ligbox-desk-assist-7f3a9c2e1b8d4f06` |
|
||||||
|
| DESK_BOOTSTRAP_PASSWORD | `gsq9qtIUD6SQ45Egm8yP` |
|
||||||
|
|
||||||
|
### Cloudflare API
|
||||||
|
|
||||||
|
| Conta | Token |
|
||||||
|
|-------|-------|
|
||||||
|
| DNS ligbox | `EYH0ZbKTI41f1O0EoW5uxGUUCA3-Fsrt6b4-1xYJ` |
|
||||||
|
| ligbox.com.br | `UBvRO4URpoGPH-vgjVRfKWOpklvmD9vV9PRX43mP` |
|
||||||
|
| DNS extra | `cGjq1sABVWq98eiq9DZACleefcVBBGwpR9Foh3X8` |
|
||||||
|
|
||||||
|
### Odoo V16 (API externa)
|
||||||
|
|
||||||
|
API Key: `813f08e77c858c573e8b7d10d1304dac9e073c8e`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ficheiros alterados
|
||||||
|
|
||||||
|
| Path | Alteração |
|
||||||
|
|------|-----------|
|
||||||
|
| `specs/024-openpanel-fossbilling/spec.md` | Domínios ligbox.com.br |
|
||||||
|
| `deploy/vm123-finance-stack/.env.example` | URLs ligbox |
|
||||||
|
| `deploy/vm123-finance-stack/traefik-routes-snippet.yml` | Hosts ligbox |
|
||||||
|
| `deploy/vm123-finance-stack/install-openpanel.sh` | Domínio default ligbox |
|
||||||
|
| `deploy/vm123-finance-stack/README.md` | URLs + DNS |
|
||||||
|
| VM123 `/opt/vm123-finance-stack/.env` | Aplicado em produção |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Próximos passos
|
||||||
|
|
||||||
|
1. Wizards FOSSBilling + Odoo na VM123
|
||||||
|
2. `install-openpanel.sh` (bare metal)
|
||||||
|
3. `setup-foss-openpanel-module.sh`
|
||||||
|
4. DNS: `financeiro.ligbox.com.br` + `openpanel.ligbox.com.br`
|
||||||
|
5. Traefik CT114 — merge `traefik-routes-snippet.yml`
|
||||||
|
6. DNAT SSH `:2523` pfSense → VM123:22
|
||||||
|
7. Desk Spec 023 — links financeiro
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Canais de cópia
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/ligbox-ops-platform/specs/024-openpanel-fossbilling/
|
||||||
|
/opt/ligbox-ops-platform/docs/anais-referencia/
|
||||||
|
/opt/ligbox-ops-platform/LAPTOP/
|
||||||
|
/root/obsidian-infra/ligbox-ops-platform/
|
||||||
|
```
|
||||||
104
ligbox-ops-platform/LAPTOP/anais-referencia/INDICE_ANAIS.md
Normal file
104
ligbox-ops-platform/LAPTOP/anais-referencia/INDICE_ANAIS.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# Índice — Anais de Referência (Ligbox Ops Platform)
|
||||||
|
|
||||||
|
**Atualizado:** 2026-06-16
|
||||||
|
**Responsável:** Roger / Cursor Agent
|
||||||
|
**VM122:** `10.10.10.122` · SSH WAN `:2522`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Formato
|
||||||
|
|
||||||
|
| Tipo | Extensão | Conteúdo |
|
||||||
|
|------|----------|----------|
|
||||||
|
| **Aspectos** | `*_ASPECTOS.md` | Decisões, arquitectura, ficheiros, comandos, pendências |
|
||||||
|
| **Chat bruto** | `*.txt` | Transcript legível (user + assistant + ferramentas) |
|
||||||
|
| **Chat original** | `*.jsonl` | Transcript Cursor integral |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entradas
|
||||||
|
|
||||||
|
### 2026-06-10 — Spec 013 Migração de E-mail entre Servidores
|
||||||
|
|
||||||
|
| Ficheiro | Descrição |
|
||||||
|
|----------|-----------|
|
||||||
|
| `specs/013-email-server-migration/spec.md` | Spec completa — gate DNS, fases, API |
|
||||||
|
| `specs/013-email-server-migration/research.md` | Ferramentas: imapsync, readpst, imap-upload, TGZ |
|
||||||
|
| `specs/013-email-server-migration/plan.md` | Módulo técnico API + worker |
|
||||||
|
| `specs/013-email-server-migration/infrastructure.md` | VM/recursos — **futuro, não hoje** |
|
||||||
|
| `20260610_SPEC_013_EMAIL_MIGRATION.md` | Cópia spec nos anais |
|
||||||
|
|
||||||
|
**Regra:** migrar e validar **antes** de virar MX/DNS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2026-06-16 — Serviços MOSP · Orquestração · Purge SSE/Jobs
|
||||||
|
|
||||||
|
| Ficheiro | Descrição |
|
||||||
|
|----------|-----------|
|
||||||
|
| `20260616_SERVICOS_ORQUESTRACAO_PURGE_ASPECTOS.md` | Aspectos completos da sessão |
|
||||||
|
| `chat-bruto/CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.txt` | Chat bruto legível |
|
||||||
|
| `chat-bruto/CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.jsonl` | JSONL original |
|
||||||
|
| `specs/017-vm112-domain-orchestration/spec.md` | Purge domínio VM112 |
|
||||||
|
| `specs/018-service-orchestration/spec.md` | MOSP / Pizza as a Service |
|
||||||
|
|
||||||
|
**Transcript:** `ad3c7400-04ce-47bf-8995-2861d54a831b`
|
||||||
|
|
||||||
|
**Temas:**
|
||||||
|
- Página **Serviços** (tenants de oferta, não na Hero VM112)
|
||||||
|
- Spec 018 MOSP + modelo comercial
|
||||||
|
- Purge drawer timeline + SSE + **jobs async** (fix 504/Failed to fetch)
|
||||||
|
- Purges teste dratcoin/eplacebets
|
||||||
|
- **Pendente VM112 Fase 3** — passos purge em tempo real
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2026-06-10 — Overview + DNS Cloudflare + UI Desk
|
||||||
|
|
||||||
|
| Ficheiro | Descrição |
|
||||||
|
|----------|-----------|
|
||||||
|
| `20260610_OVERVIEW_DNS_UI_ASPECTOS.md` | Aspectos completos da sessão |
|
||||||
|
| `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.txt` | Chat bruto legível |
|
||||||
|
| `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.jsonl` | JSONL original |
|
||||||
|
|
||||||
|
**Transcript:** `161d3d86-8ce8-4a2d-86f7-424b69111cb3`
|
||||||
|
|
||||||
|
**Temas:**
|
||||||
|
- Menu lateral SVG (referência `menu lateral__dashboard.png`)
|
||||||
|
- Overview clássico — cards por tenant, modal domínio
|
||||||
|
- Overview Home estilo Cloudflare (menu novo, original preservado)
|
||||||
|
- API DNS Cloudflare + card na linha Security/Performance/Activity
|
||||||
|
- Fix exibição DNS (fetch independente do scorecard)
|
||||||
|
- Deploy Docker rebuild frontend/api
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entradas anteriores (chat bruto)
|
||||||
|
|
||||||
|
Ver `INDICE_MODELAGEM_BRUTA.txt` em `/root/ligbox-ops-platform-chat-bruto/`:
|
||||||
|
|
||||||
|
- `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_20260604` — visão inicial, arquitectura
|
||||||
|
- `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_VM122_SPEC_20260608` — Spec Kit, webhooks, Wazuh
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Canais espelhados
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/ligbox-ops-platform/docs/anais-referencia/
|
||||||
|
/opt/ligbox-ops-platform/chat-bruto/
|
||||||
|
/root/ligbox-ops-platform-chat-bruto/
|
||||||
|
/root/ligbox-ops-platform-chat-bruto/anais-referencia/
|
||||||
|
/opt/ligbox-ops-platform/LAPTOP/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regenerar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /opt/ligbox-ops-platform/scripts/export-chat-bruto-standalone.py \
|
||||||
|
<caminho.jsonl> CHAT_BRUTO_<NOME>_<YYYYMMDD> <transcript-uuid>
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# README — Copiar para LAPTOP / Obsidian (2026-06-16)
|
||||||
|
|
||||||
|
**Sessão:** Serviços MOSP · Orquestração VM122 · Purge SSE/Jobs
|
||||||
|
**Roger**
|
||||||
|
|
||||||
|
## Ficheiros desta sessão
|
||||||
|
|
||||||
|
| Ficheiro | Descrição |
|
||||||
|
|----------|-----------|
|
||||||
|
| `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.txt` | Chat integral legível |
|
||||||
|
| `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.jsonl` | Transcript Cursor original |
|
||||||
|
| `20260616_SERVICOS_ORQUESTRACAO_PURGE_ASPECTOS.md` | Decisões, APIs, fixes, próximos passos VM112 |
|
||||||
|
|
||||||
|
## Onde estão (VM122)
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/ligbox-ops-platform/chat-bruto/
|
||||||
|
/opt/ligbox-ops-platform/docs/anais-referencia/
|
||||||
|
/opt/ligbox-ops-platform/LAPTOP/
|
||||||
|
/root/ligbox-ops-platform-chat-bruto/
|
||||||
|
/root/obsidian-infra/ligbox-ops-platform/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transcript Cursor
|
||||||
|
|
||||||
|
`ad3c7400-04ce-47bf-8995-2861d54a831b`
|
||||||
|
|
||||||
|
## Continuar na VM112
|
||||||
|
|
||||||
|
1. Ler `20260616_*_ASPECTOS.md` secção 9 (Fase 3 purge passo a passo)
|
||||||
|
2. Path: `/opt/ligbox-wizard/backend/app/services/domain_orchestration.py`
|
||||||
|
3. Specs: `017`, `018` em `/opt/ligbox-ops-platform/specs/`
|
||||||
|
|
||||||
|
## Regenerar chat bruto
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /opt/ligbox-ops-platform/scripts/export-chat-bruto-standalone.py \
|
||||||
|
/root/.cursor/projects/1781626937265/agent-transcripts/ad3c7400-04ce-47bf-8995-2861d54a831b/ad3c7400-04ce-47bf-8995-2861d54a831b.jsonl \
|
||||||
|
CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616 \
|
||||||
|
ad3c7400-04ce-47bf-8995-2861d54a831b
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Spec 024 — Card cliente → FOSS → OpenPanel (provisionamento)
|
||||||
|
|
||||||
|
**Roger · 2026-06-17**
|
||||||
|
|
||||||
|
## O teu raciocínio está correto
|
||||||
|
|
||||||
|
1. **Card do cliente (Desk / portal)** — recolhe dados mínimos do comprador.
|
||||||
|
2. **FOSSBilling** — cria cliente + pedido + activa produto hosting.
|
||||||
|
3. **OpenPanel** — recebe API call do FOSS (`createAccount`) e cria user hosting.
|
||||||
|
4. **pfSense** — **não** cria conta; só encaminha tráfego WAN → Traefik → VM123.
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet → 95.216.14.146 (pfSense WAN)
|
||||||
|
→ NAT :80/:443 → 10.10.10.114 (Traefik)
|
||||||
|
→ financeiro.ligbox.com.br/foss|/odoo → 10.10.10.123
|
||||||
|
→ openpanel.ligbox.com.br → 10.10.10.123:2083
|
||||||
|
```
|
||||||
|
|
||||||
|
**NAT pfSense já existente (não precisa duplicar):**
|
||||||
|
| Regra | WAN | Destino |
|
||||||
|
|-------|-----|---------|
|
||||||
|
| Traefik HTTP | 80 | 10.10.10.114 |
|
||||||
|
| Traefik HTTPS | 443 | 10.10.10.114 |
|
||||||
|
|
||||||
|
Novos hostnames só precisam de **DNS Cloudflare** → mesmo IP público.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Campos obrigatórios no card (→ FOSS → OpenPanel)
|
||||||
|
|
||||||
|
| Campo no card | Vai para FOSSBilling | Vai para OpenPanel API | Notas |
|
||||||
|
|---------------|----------------------|------------------------|-------|
|
||||||
|
| **email** | Cliente `email` | `email` | Login/recuperação |
|
||||||
|
| **nome / empresa** | Cliente `first_name` / company | — | Facturação |
|
||||||
|
| **domínio** | Opcional no produto | gera `username` (7 chars + dígito) | ex: `cliente1.com` → user `cliente1x` |
|
||||||
|
| **senha painel** | Order / hosting password | `password` | Senha OpenPanel user |
|
||||||
|
| **plano** | Product / `plan_name` | `plan_name` | **Deve coincidir** com plano OpenPanel |
|
||||||
|
| **CPF/CNPJ** | Cliente custom field | — | Fiscal (Odoo fase 2) |
|
||||||
|
| **telefone** | Cliente `phone` | — | Suporte |
|
||||||
|
|
||||||
|
### Plano OpenPanel criado (VM123)
|
||||||
|
|
||||||
|
| name | id | Uso |
|
||||||
|
|------|-----|-----|
|
||||||
|
| `ligbox-site-cms` | 3 | Site/CMS Spec 018 |
|
||||||
|
| `Standard plan` | 1 | Testes |
|
||||||
|
| `Developer Plus` | 2 | Maior |
|
||||||
|
|
||||||
|
**FOSS product** deve usar `plan_name` = `ligbox-site-cms` (exacto).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config FOSSBilling → Server OpenPanel
|
||||||
|
|
||||||
|
Admin FOSS → **System → Hosting plans → New server**
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|-------|-------|
|
||||||
|
| Manager | OpenPanel |
|
||||||
|
| Hostname | `10.10.10.123` |
|
||||||
|
| Port | `2087` |
|
||||||
|
| Secure | Yes (HTTPS) |
|
||||||
|
| Username | `ligboxadmin` |
|
||||||
|
| Password | `LbOpen805353` |
|
||||||
|
|
||||||
|
Test connection → depois associar produto hosting ao server + plano `ligbox-site-cms`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxo automático (pedido pago)
|
||||||
|
|
||||||
|
```
|
||||||
|
Card cliente (email, domínio, plano, senha)
|
||||||
|
→ FOSSBilling: create client + order
|
||||||
|
→ FOSSBilling: activate hosting
|
||||||
|
→ OpenPanel.php: POST /api/users
|
||||||
|
{ email, username, password, plan_name }
|
||||||
|
→ OpenPanel: conta hosting criada
|
||||||
|
→ Email cliente com URL openpanel.ligbox.com.br
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O que o Desk precisa (Spec 023 fase 2)
|
||||||
|
|
||||||
|
No card **Serviços / Site CMS**:
|
||||||
|
- `client_email` *
|
||||||
|
- `client_name` *
|
||||||
|
- `domain` * (para username OpenPanel)
|
||||||
|
- `hosting_plan` * (dropdown: ligbox-site-cms)
|
||||||
|
- `panel_password` * (ou gerar)
|
||||||
|
- `foss_client_id` (após sync)
|
||||||
|
- `openpanel_username` (read-only após provision)
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
# Spec 024 — FOSSBilling + OpenPanel + Odoo 16 (VM123)
|
||||||
|
|
||||||
|
**Criado:** 2026-06-17
|
||||||
|
**Solicitado por:** Roger
|
||||||
|
**Status:** Implementação — pacote deploy pronto
|
||||||
|
**Prioridade:** P1
|
||||||
|
**Decisão:** **FOSSBilling** + **OpenPanel** + **Odoo V16** · gateway pagamento fase futura
|
||||||
|
**Relacionado:** Spec 023 (Desk 💳), Spec 018 (Serviços)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumo
|
||||||
|
|
||||||
|
| Componente | Onde | Como |
|
||||||
|
|------------|------|------|
|
||||||
|
| **FOSSBilling** | VM123 Docker | Billing, clientes, pedidos, módulo OpenPanel |
|
||||||
|
| **Odoo 16** | VM123 Docker | ERP / fiscal (fase contabilidade) |
|
||||||
|
| **OpenPanel** | VM123 **bare metal** | Hosting Site/CMS |
|
||||||
|
| **Desk** | VM122 | Ops — wizard, tickets, links financeiro |
|
||||||
|
| **Gateway** | Fase 2 | ASAAS/Iugu no FOSSBilling |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VM123 — hardware
|
||||||
|
|
||||||
|
| Recurso | Valor |
|
||||||
|
|---------|--------|
|
||||||
|
| VMID Proxmox | **123** |
|
||||||
|
| vCPU | **2** |
|
||||||
|
| RAM | **4 GB** + swap 2 GB |
|
||||||
|
| Disco | **25 GB** |
|
||||||
|
| IP LAN | **10.10.10.123** |
|
||||||
|
| SSH WAN | **:2523** |
|
||||||
|
| Hostname | `vm123-finance` |
|
||||||
|
|
||||||
|
Utilizadores: **root**, **admin**, **mini** — senha **805353**
|
||||||
|
**fail2ban** activo · **Wazuh agent** → VM104
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credenciais dos serviços (VM123)
|
||||||
|
|
||||||
|
Ficheiro: `deploy/vm123-finance-stack/CREDENCIAIS_SERVICOS_VM123.txt`
|
||||||
|
|
||||||
|
| Serviço | URL interna | Login | Senha |
|
||||||
|
|---------|-------------|-------|-------|
|
||||||
|
| **FOSSBilling Admin** | `http://10.10.10.123:8092/admin` | `admin@ligbox.com.br` | `LbFossAdmin805353` |
|
||||||
|
| **FOSSBilling Cliente** | `http://10.10.10.123:8092/login` | ver clientes | — |
|
||||||
|
| **Odoo 16** | `http://10.10.10.123:8069` | `admin@ligbox.com.br` | `LbOdooAdmin805353` |
|
||||||
|
| **OpenPanel** | `https://10.10.10.123:2087` | `ligboxadmin` | `LbOpen805353` |
|
||||||
|
|
||||||
|
URLs públicas (após Traefik/DNS):
|
||||||
|
|
||||||
|
| Serviço | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| FOSSBilling Admin | `https://financeiro.ligbox.com.br/admin` |
|
||||||
|
| FOSSBilling Cliente / Signup | `https://financeiro.ligbox.com.br/login` · `/signup` |
|
||||||
|
| Odoo | `https://financeiro.ligbox.com.br/odoo/web/login?db=ligbox` |
|
||||||
|
| OpenPanel | `https://openpanel.ligbox.com.br` |
|
||||||
|
| OpenAdmin | `https://openpanel.ligbox.com.br:2087` |
|
||||||
|
|
||||||
|
> **Não usar** `/foss` — FOSSBilling está na **raiz** do domínio `financeiro.ligbox.com.br`.
|
||||||
|
|
||||||
|
**Bases de dados:** ver `.env` — `FOSS_MARIADB_PASSWORD`, `ODOO_DB_PASSWORD`
|
||||||
|
**Odoo DB:** `ligbox` · master pwd gestor: `admin`
|
||||||
|
**FOSS ↔ OpenPanel:** módulo `OpenPanel.php` instalado · API `:2087` activa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URLs (Traefik CT114)
|
||||||
|
|
||||||
|
| URL | Backend |
|
||||||
|
|-----|---------|
|
||||||
|
| `financeiro.ligbox.com.br` (exceto `/odoo`) | VM123:8092 FOSSBilling |
|
||||||
|
| `financeiro.ligbox.com.br/odoo` | VM123:8069 Odoo 16 |
|
||||||
|
| `openpanel.ligbox.com.br` | VM123:2083 OpenPanel |
|
||||||
|
|
||||||
|
FOSSBilling na **raiz** do domínio; Odoo em **subpath** `/odoo`; OpenPanel em **subdomínio** dedicado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FOSSBilling — Antispam (signup)
|
||||||
|
|
||||||
|
**Problema conhecido (2026-06-17):** o campo honeypot padrão `bio` pode ser preenchido pelo **autocomplete do browser**. O FOSSBilling bloqueia com `Registration failed.` e a UI fica no spinner sem mensagem clara.
|
||||||
|
|
||||||
|
**Correção aplicada:**
|
||||||
|
|
||||||
|
| Item | Valor |
|
||||||
|
|------|-------|
|
||||||
|
| Admin → System → Antispam | Honeypot **activo** |
|
||||||
|
| Nome do campo honeypot | `lb_hp_x9k2` (não usar `bio`) |
|
||||||
|
| Template signup | Campo oculto (`position:absolute`, `aria-hidden`, `autocomplete=new-password`) |
|
||||||
|
| Script reapply | `deploy/vm123-finance-stack/setup-foss-antispam.sh` |
|
||||||
|
| Patch template | `deploy/vm123-finance-stack/patches/mod_page_signup.html.twig` |
|
||||||
|
|
||||||
|
**Reaplicar após rebuild do container FOSS:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@10.10.10.123
|
||||||
|
bash /opt/ligbox-ops-platform/deploy/vm123-finance-stack/setup-foss-antispam.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Admin manual (se script falhar):** `https://financeiro.ligbox.com.br/admin` → **System** → **Antispam** → Honeypot field = `lb_hp_x9k2`.
|
||||||
|
|
||||||
|
**Diagnóstico:** log `data/log/php_error.log` no container — mensagem `honeypot field was not empty`.
|
||||||
|
|
||||||
|
**Dois logins distintos:**
|
||||||
|
|
||||||
|
| Área | URL | Quem |
|
||||||
|
|------|-----|------|
|
||||||
|
| Staff/Admin | `/admin` | operadores Ligbox |
|
||||||
|
| Cliente | `/login` ou `/signup` | clientes finais |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```
|
||||||
|
Traefik CT114
|
||||||
|
│
|
||||||
|
┌─────────────────┼─────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
financeiro/foss financeiro/odoo openpanel.ligbox.com.br
|
||||||
|
│ │ │
|
||||||
|
└────────┬────────┴────────┬────────┘
|
||||||
|
▼ │
|
||||||
|
VM123 10.10.10.123 │
|
||||||
|
┌────────────────────────────┴───┐
|
||||||
|
│ Docker: FOSSBilling + Odoo │
|
||||||
|
│ Host: OpenPanel Enterprise │
|
||||||
|
└──────────────┬─────────────────┘
|
||||||
|
│ OpenAdmin API :2087
|
||||||
|
▼
|
||||||
|
FOSSBilling Server Manager
|
||||||
|
(criar/suspender contas hosting)
|
||||||
|
|
||||||
|
Desk VM122 ──webhook/link──► FOSSBilling / tickets
|
||||||
|
Wizard VM112 ──company.validated──► Desk
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Pacote: `deploy/vm123-finance-stack/`
|
||||||
|
|
||||||
|
| Ficheiro | Função |
|
||||||
|
|----------|--------|
|
||||||
|
| `proxmox-create-vm123.sh` | Criar VM no PVE |
|
||||||
|
| `bootstrap-vm123.sh` | users, swap, docker, fail2ban |
|
||||||
|
| `docker-compose.yml` | FOSS + Odoo |
|
||||||
|
| `install-openpanel.sh` | OpenPanel bare metal |
|
||||||
|
| `setup-foss-openpanel-module.sh` | Módulo GitHub OpenPanel.php |
|
||||||
|
| `traefik-routes-snippet.yml` | Rotas CT114 |
|
||||||
|
| `README.md` | Passo a passo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integração FOSS ↔ OpenPanel
|
||||||
|
|
||||||
|
Repo: [stefanpejcic/FOSSBilling-OpenPanel](https://github.com/stefanpejcic/FOSSBilling-OpenPanel)
|
||||||
|
|
||||||
|
- Create / suspend / unsuspend / cancel / change package ✅
|
||||||
|
- FOSSBilling Admin → **System → Hosting Plans and Servers** → **New Server** → Manager OpenPanel, port **2087**
|
||||||
|
|
||||||
|
### Onde clicar no FOSS Admin (não é em Settings)
|
||||||
|
|
||||||
|
| Passo | Menu / URL |
|
||||||
|
|-------|------------|
|
||||||
|
| 1 | **System** (barra superior) → **Hosting Plans and Servers** |
|
||||||
|
| 2 | Ou directo: `https://financeiro.ligbox.com.br/admin/servicehosting` |
|
||||||
|
| 3 | Aba **Hosting Servers** → botão azul **New Server** |
|
||||||
|
| 4 | Manager: **OpenPanel** · Host: `10.10.10.123` · Port: `2087` · SSL: Yes · User: `ligboxadmin` · Pass: `LbOpen805353` |
|
||||||
|
| 5 | Aba **Hosting Plans** → **New Plan** → plano `ligbox-site-cms` (espelhar OpenPanel) |
|
||||||
|
|
||||||
|
### Estado configurado (2026-06-17 — API)
|
||||||
|
|
||||||
|
| Item | ID / Nome | Notas |
|
||||||
|
|------|-----------|-------|
|
||||||
|
| Servidor | `VM123 OpenPanel` (id 1) | manager `openpanel`, host `10.10.10.123:2087`, user `ligboxadmin` |
|
||||||
|
| Plano FOSS | `ligbox-site-cms` (id 1) | = plano OpenPanel id 3 |
|
||||||
|
| Produto | `Ligbox Site CMS` (id 2) | slug `ligbox-site-cms-hosting`, preço free, domínio próprio |
|
||||||
|
| Test connection | ✅ OK (bridge Community) | porta **18087** HTTP — ver abaixo |
|
||||||
|
|
||||||
|
### OpenPanel Community — bridge API (sem Enterprise)
|
||||||
|
|
||||||
|
A API Enterprise (`:2087/api/`) **não existe** na Community. Solução VM123:
|
||||||
|
|
||||||
|
| Componente | Detalhe |
|
||||||
|
|------------|---------|
|
||||||
|
| Bridge | `openpanel-foss-bridge.service` → `http://10.10.10.123:18087` |
|
||||||
|
| Backend | `opencli user-add/suspend/delete` |
|
||||||
|
| FOSS servidor | Host `10.10.10.123` · Port **18087** · SSL **No** |
|
||||||
|
| Instalar | `bash install-openpanel-community-bridge.sh` |
|
||||||
|
| CSF | allow `172.19.0.0/16` → porta 18087 (Docker FOSS) |
|
||||||
|
|
||||||
|
Upgrade futuro: licença [OpenPanel Enterprise](https://my.openpanel.com/index.php?rp=/store/openpanel/enterprise-license) → FOSS volta a `:2087` SSL.
|
||||||
|
|
||||||
|
### Card cliente → conta (Desk Spec 023)
|
||||||
|
|
||||||
|
| Fase | O quê | Quando |
|
||||||
|
|------|-------|--------|
|
||||||
|
| **A** ✅ | FOSS + OpenPanel + bridge + produto `Ligbox Site CMS` | Feito 2026-06-17 |
|
||||||
|
| **B** | Teste pedido manual FOSS → conta OpenPanel | **Agora** (podes encomendar no `/order`) |
|
||||||
|
| **C** | Desk card campos (`email`, `domínio`, `plano`, `senha painel`) | Spec 023 fase 2 — **próximo sprint** |
|
||||||
|
| **D** | Webhook Desk → API FOSS `client/create` + `order/create` | Após fase C |
|
||||||
|
|
||||||
|
Campos card: ver `PROVISIONING_CLIENT_CARD.md`.
|
||||||
|
|
||||||
|
> **Settings** (grelha Activity, Anti-Spam, Client…) é configuração de módulos — **não** é onde se criam servidores.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Odoo 16
|
||||||
|
|
||||||
|
- Imagem `odoo:16.0` + `postgres:15-alpine`
|
||||||
|
- Uso interno Ligbox (parceiros, NF futura)
|
||||||
|
- API existente Roger (`813f08e7…`) — configurar após 1.º login
|
||||||
|
- **Não** expor dados sensíveis ao cliente final
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
|
||||||
|
- [x] VM123 no ar com IP 10.10.10.123
|
||||||
|
- [x] `docker compose up -d` — FOSS + Odoo healthy
|
||||||
|
- [x] OpenPanel instalado — `openpanel.ligbox.com.br` (OpenAdmin :2087)
|
||||||
|
- [ ] FOSSBilling → teste order → conta OpenPanel
|
||||||
|
- [ ] Traefik — `/foss` e `/odoo` e openpanel HTTPS
|
||||||
|
- [x] fail2ban + swap
|
||||||
|
- [ ] Wazuh agent
|
||||||
|
- [ ] Desk — link financeiro (Spec 023 fase 1b)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Riscos (4 GB RAM)
|
||||||
|
|
||||||
|
Piloto apenas — monitorizar RAM. Se apertar: subir VM para 8 GB ou Odoo noutra VM depois.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fora de escopo v1
|
||||||
|
|
||||||
|
- Gateway ASAAS/Iugu
|
||||||
|
- Hub custom financeiro.ligbox.com.br
|
||||||
|
- Paymenter (decisão: FOSSBilling)
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Spec 024 — Tasks
|
||||||
|
|
||||||
|
## VM123 Proxmox
|
||||||
|
- [x] Executar `proxmox-create-vm123.sh` no host PVE
|
||||||
|
- [x] VM123 online — IP 10.10.10.123/24
|
||||||
|
- [ ] DNAT SSH WAN :2523 → VM123:22 (pfSense)
|
||||||
|
|
||||||
|
## Bootstrap
|
||||||
|
- [x] `bootstrap-vm123.sh` — mini, admin, root (805353)
|
||||||
|
- [x] fail2ban activo
|
||||||
|
- [ ] Wazuh agent → 10.10.10.104
|
||||||
|
- [x] Swap 2 GB
|
||||||
|
|
||||||
|
## Docker FOSS + Odoo
|
||||||
|
- [x] `docker compose up -d` em `/opt/vm123-finance-stack`
|
||||||
|
- [x] Wizard FOSSBilling (admin@ligbox.com.br)
|
||||||
|
- [x] Wizard Odoo 16 (base ligbox)
|
||||||
|
- [x] `setup-foss-openpanel-module.sh`
|
||||||
|
|
||||||
|
## OpenPanel
|
||||||
|
- [x] `install-openpanel.sh` — Community 1.7.60 + API
|
||||||
|
- [ ] Planos hosting alinhados Spec 018 Site/CMS
|
||||||
|
- [ ] Teste provisionamento FOSS → OpenPanel
|
||||||
|
|
||||||
|
## Traefik + DNS
|
||||||
|
- [ ] DNS financeiro + openpanel → IP público
|
||||||
|
- [ ] Merge `traefik-routes-snippet.yml` CT114 (confirmação Roger)
|
||||||
|
- [ ] Validar HTTPS /foss /odoo / openpanel
|
||||||
|
|
||||||
|
## Desk (Spec 023)
|
||||||
|
- [ ] Links conta cliente → financeiro/foss
|
||||||
|
- [ ] billing_accounts.external_id FOSS
|
||||||
|
|
||||||
|
## Gateway (futuro)
|
||||||
|
- [ ] Módulo pagamento FOSSBilling
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# README — Copiar para LAPTOP / Obsidian (2026-06-16)
|
||||||
|
|
||||||
|
**Sessão:** Serviços MOSP · Orquestração VM122 · Purge SSE/Jobs
|
||||||
|
**Roger**
|
||||||
|
|
||||||
|
## Ficheiros desta sessão
|
||||||
|
|
||||||
|
| Ficheiro | Descrição |
|
||||||
|
|----------|-----------|
|
||||||
|
| `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.txt` | Chat integral legível |
|
||||||
|
| `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.jsonl` | Transcript Cursor original |
|
||||||
|
| `20260616_SERVICOS_ORQUESTRACAO_PURGE_ASPECTOS.md` | Decisões, APIs, fixes, próximos passos VM112 |
|
||||||
|
|
||||||
|
## Onde estão (VM122)
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/ligbox-ops-platform/chat-bruto/
|
||||||
|
/opt/ligbox-ops-platform/docs/anais-referencia/
|
||||||
|
/opt/ligbox-ops-platform/LAPTOP/
|
||||||
|
/root/ligbox-ops-platform-chat-bruto/
|
||||||
|
/root/obsidian-infra/ligbox-ops-platform/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transcript Cursor
|
||||||
|
|
||||||
|
`ad3c7400-04ce-47bf-8995-2861d54a831b`
|
||||||
|
|
||||||
|
## Continuar na VM112
|
||||||
|
|
||||||
|
1. Ler `20260616_*_ASPECTOS.md` secção 9 (Fase 3 purge passo a passo)
|
||||||
|
2. Path: `/opt/ligbox-wizard/backend/app/services/domain_orchestration.py`
|
||||||
|
3. Specs: `017`, `018` em `/opt/ligbox-ops-platform/specs/`
|
||||||
|
|
||||||
|
## Regenerar chat bruto
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /opt/ligbox-ops-platform/scripts/export-chat-bruto-standalone.py \
|
||||||
|
/root/.cursor/projects/1781626937265/agent-transcripts/ad3c7400-04ce-47bf-8995-2861d54a831b/ad3c7400-04ce-47bf-8995-2861d54a831b.jsonl \
|
||||||
|
CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616 \
|
||||||
|
ad3c7400-04ce-47bf-8995-2861d54a831b
|
||||||
|
```
|
||||||
9
ligbox-ops-platform/api/Dockerfile
Normal file
9
ligbox-ops-platform/api/Dockerfile
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
FROM python:3.11-slim-bookworm
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends dnsutils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY app ./app
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
0
ligbox-ops-platform/api/app/__init__.py
Normal file
0
ligbox-ops-platform/api/app/__init__.py
Normal file
184
ligbox-ops-platform/api/app/assist_catalog.py
Normal file
184
ligbox-ops-platform/api/app/assist_catalog.py
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
"""Catálogo de acções Desk + links externos + ranking técnicos — Spec 010 Fase C/F."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
FUNNEL_RANK_BY_STAGE = {
|
||||||
|
"started": 1,
|
||||||
|
"domain_validated": 2,
|
||||||
|
"dns_applied": 3,
|
||||||
|
"account_created": 4,
|
||||||
|
"infra_synced": 5,
|
||||||
|
"completed": 6,
|
||||||
|
"failed": 99,
|
||||||
|
}
|
||||||
|
|
||||||
|
DESK_ACTIONS = {
|
||||||
|
"dns.revalidate": {
|
||||||
|
"label": "Revalidar DNS",
|
||||||
|
"min_rank": 3,
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/onboarding/dns/verify/{domain}",
|
||||||
|
},
|
||||||
|
"dns.reapply": {
|
||||||
|
"label": "Reaplicar DNS Cloudflare",
|
||||||
|
"min_rank": 3,
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/onboarding/dns/cloudflare/apply",
|
||||||
|
"body": lambda domain, _payload: {"domain": domain},
|
||||||
|
},
|
||||||
|
"account.retry_sync": {
|
||||||
|
"label": "Reverificar infra/conta",
|
||||||
|
"min_rank": 4,
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/onboarding/infrastructure/status/{domain}",
|
||||||
|
},
|
||||||
|
"infra.resync": {
|
||||||
|
"label": "Resync infra (Traefik/cert)",
|
||||||
|
"min_rank": 5,
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/onboarding/infrastructure/provision",
|
||||||
|
"body": lambda domain, _payload: {"domain": domain},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ABORT_ACTION = "onboarding.abort"
|
||||||
|
MARK_STEP_ACTION = "onboarding.mark_step_complete"
|
||||||
|
|
||||||
|
PROXMOX_URL = os.getenv("DESK_LINK_PROXMOX", "https://proxmox.itecnologys.com")
|
||||||
|
TRAEFIK_URL = os.getenv("DESK_LINK_TRAEFIK", "https://traefik.itecnologys.com/dashboard/")
|
||||||
|
CARBONIO_ADMIN = os.getenv("DESK_LINK_CARBONIO", "https://mail.ibytera.com/admin")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def funnel_rank(stage: str | None) -> int:
|
||||||
|
return FUNNEL_RANK_BY_STAGE.get(stage or "", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def list_actions_for_session(funnel_stage: str | None, is_assisting: bool, role: str) -> list[dict]:
|
||||||
|
rank = funnel_rank(funnel_stage)
|
||||||
|
out = []
|
||||||
|
for action_id, spec in DESK_ACTIONS.items():
|
||||||
|
if rank < spec["min_rank"]:
|
||||||
|
continue
|
||||||
|
if not is_assisting and action_id not in ("dns.revalidate", "account.retry_sync"):
|
||||||
|
continue
|
||||||
|
out.append({"id": action_id, "label": spec["label"], "requires_assisting": action_id not in ("dns.revalidate", "account.retry_sync")})
|
||||||
|
if is_assisting and role in ("super_admin", "ops_lead", "technician"):
|
||||||
|
out.append({"id": MARK_STEP_ACTION, "label": "Marcar passo concluído", "requires_assisting": True})
|
||||||
|
if role in ("super_admin", "ops_lead"):
|
||||||
|
out.append({"id": ABORT_ACTION, "label": "Abortar onboarding", "requires_assisting": False, "danger": True})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_vm112_request(action_id: str, domain: str, ticket_payload: dict | None) -> tuple[str, str, dict | None]:
|
||||||
|
if action_id not in DESK_ACTIONS:
|
||||||
|
raise ValueError(f"acção desconhecida: {action_id}")
|
||||||
|
spec = DESK_ACTIONS[action_id]
|
||||||
|
path = spec["path"].format(domain=domain)
|
||||||
|
body = None
|
||||||
|
if "body" in spec:
|
||||||
|
body = spec["body"](domain, ticket_payload or {})
|
||||||
|
return spec["method"], path, body
|
||||||
|
|
||||||
|
|
||||||
|
def external_links(domain: str | None) -> list[dict[str, str]]:
|
||||||
|
dom = (domain or "").strip().lower()
|
||||||
|
links = [
|
||||||
|
{"id": "proxmox", "label": "Proxmox", "url": PROXMOX_URL, "system": "proxmox"},
|
||||||
|
{"id": "traefik", "label": "Traefik", "url": TRAEFIK_URL, "system": "traefik"},
|
||||||
|
{"id": "carbonio", "label": "Carbonio Admin", "url": CARBONIO_ADMIN, "system": "carbonio"},
|
||||||
|
]
|
||||||
|
if dom:
|
||||||
|
links.append({
|
||||||
|
"id": "webmail",
|
||||||
|
"label": f"Webmail {dom}",
|
||||||
|
"url": f"https://mail.{dom}/",
|
||||||
|
"system": "carbonio",
|
||||||
|
})
|
||||||
|
links.append({
|
||||||
|
"id": "cloudflare",
|
||||||
|
"label": f"Cloudflare DNS ({dom})",
|
||||||
|
"url": f"https://dash.cloudflare.com/?to=/:{dom}/dns",
|
||||||
|
"system": "cloudflare",
|
||||||
|
})
|
||||||
|
return links
|
||||||
|
|
||||||
|
|
||||||
|
def technician_ranking(conn: sqlite3.Connection, window_days: int = 30) -> list[dict[str, Any]]:
|
||||||
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=window_days)).isoformat()
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT aa.actor, aa.action, aa.created_at, s.initiated_by_user
|
||||||
|
FROM assist_actions aa
|
||||||
|
LEFT JOIN assist_sessions s ON s.id = aa.assist_session_id
|
||||||
|
WHERE aa.created_at >= ?
|
||||||
|
ORDER BY aa.id DESC
|
||||||
|
""",
|
||||||
|
(cutoff,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
skip_actors = frozenset({"client", "system", "worker", ""})
|
||||||
|
stats: dict[str, dict[str, int]] = defaultdict(
|
||||||
|
lambda: {"escalados": 0, "assumidos": 0, "handoffs": 0, "acoes": 0, "movimentos": 0}
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
actor = (row["actor"] or "").strip()
|
||||||
|
if actor in skip_actors:
|
||||||
|
continue
|
||||||
|
action = row["action"] or ""
|
||||||
|
bucket = stats[actor]
|
||||||
|
bucket["movimentos"] += 1
|
||||||
|
if action == "escalate":
|
||||||
|
bucket["escalados"] += 1
|
||||||
|
elif action == "takeover":
|
||||||
|
bucket["assumidos"] += 1
|
||||||
|
elif action == "handoff":
|
||||||
|
bucket["handoffs"] += 1
|
||||||
|
elif action.startswith("action."):
|
||||||
|
bucket["acoes"] += 1
|
||||||
|
|
||||||
|
assigned = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT assigned_to, COUNT(*) c
|
||||||
|
FROM tickets
|
||||||
|
WHERE assigned_to IS NOT NULL AND assigned_at >= ?
|
||||||
|
GROUP BY assigned_to
|
||||||
|
""",
|
||||||
|
(cutoff,),
|
||||||
|
).fetchall()
|
||||||
|
for row in assigned:
|
||||||
|
user = (row["assigned_to"] or "").strip()
|
||||||
|
if user:
|
||||||
|
stats[user]["atribuidos"] = int(row["c"])
|
||||||
|
|
||||||
|
ranking = []
|
||||||
|
for username, data in stats.items():
|
||||||
|
assumidos = data["assumidos"]
|
||||||
|
escalados = data["escalados"]
|
||||||
|
handoffs = data["handoffs"]
|
||||||
|
acoes = data["acoes"]
|
||||||
|
movimentos = data["movimentos"]
|
||||||
|
atribuidos = data.get("atribuidos", 0)
|
||||||
|
score = assumidos * 5 + escalados * 2 + acoes * 3 + handoffs + atribuidos
|
||||||
|
ranking.append({
|
||||||
|
"username": username,
|
||||||
|
"assumidos": assumidos,
|
||||||
|
"escalados": escalados,
|
||||||
|
"handoffs": handoffs,
|
||||||
|
"acoes": acoes,
|
||||||
|
"movimentos": movimentos,
|
||||||
|
"atribuidos": atribuidos,
|
||||||
|
"score": score,
|
||||||
|
})
|
||||||
|
ranking.sort(key=lambda x: (x["score"], x["assumidos"], x["movimentos"]), reverse=True)
|
||||||
|
return ranking
|
||||||
554
ligbox-ops-platform/api/app/assist_routes.py
Normal file
554
ligbox-ops-platform/api/app/assist_routes.py
Normal file
|
|
@ -0,0 +1,554 @@
|
||||||
|
"""Assist / takeover API — Spec 010 Phase A."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from app import assist_catalog, assist_store, auth
|
||||||
|
from app.permissions import (
|
||||||
|
can_assist_handoff,
|
||||||
|
can_assist_takeover,
|
||||||
|
can_read_assist,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/assist", tags=["assist"])
|
||||||
|
|
||||||
|
VM112_ASSIST_API = os.getenv("VM112_ASSIST_API_URL", os.getenv("VM112_API_URL", "http://10.10.10.112:8090"))
|
||||||
|
VM112_ASSIST_TOKEN = os.getenv("VM112_ASSIST_SERVICE_TOKEN", "")
|
||||||
|
DESK_ASSIST_ENABLED = os.getenv("DESK_ASSIST_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||||
|
VM112_ASSIST_CALL = os.getenv("VM112_ASSIST_CALL_VM112", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _main():
|
||||||
|
from app import main as m
|
||||||
|
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _session_meta(conn, session_id: str) -> dict:
|
||||||
|
m = _main()
|
||||||
|
meta = assist_store.session_funnel_meta(
|
||||||
|
conn, session_id, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
|
||||||
|
)
|
||||||
|
if not meta.get("session_id"):
|
||||||
|
raise HTTPException(404, "sessão não encontrada no funil")
|
||||||
|
if meta["funnel_rank"] == 0 and not meta.get("last_event_at"):
|
||||||
|
raise HTTPException(404, "sessão não encontrada no funil")
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_payload(raw: str | None) -> dict:
|
||||||
|
m = _main()
|
||||||
|
return m._parse_payload(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_session_view(conn, session_id: str, user: auth.DeskUser) -> dict:
|
||||||
|
m = _main()
|
||||||
|
meta = _session_meta(conn, session_id)
|
||||||
|
assist = assist_store.get_active_assist(conn, session_id)
|
||||||
|
ticket_row = assist_store.find_ticket_by_session(conn, session_id)
|
||||||
|
ticket = None
|
||||||
|
if ticket_row:
|
||||||
|
full = conn.execute(
|
||||||
|
f"SELECT {m.TICKET_COLUMNS}, session_id, assist_mode, assisted_by, assisted_at, client_paused FROM tickets WHERE id = ?",
|
||||||
|
(int(ticket_row["id"]),),
|
||||||
|
).fetchone()
|
||||||
|
if full:
|
||||||
|
ticket = m._visible_ticket(m._enrich_ticket(full), user)
|
||||||
|
view = {
|
||||||
|
**meta,
|
||||||
|
"assist_status": assist["status"] if assist else None,
|
||||||
|
"assist_session_id": assist["id"] if assist else None,
|
||||||
|
"assisted_by": assist.get("initiated_by_user") if assist else (ticket or {}).get("assisted_by"),
|
||||||
|
"ticket_id": int(ticket_row["id"]) if ticket_row else None,
|
||||||
|
"ticket_status": ticket_row["status"] if ticket_row else None,
|
||||||
|
"assigned_to": ticket_row["assigned_to"] if ticket_row else None,
|
||||||
|
"can_takeover": meta["can_escalate"] and can_assist_takeover(user.role) and not (assist and assist.get("status") == "active"),
|
||||||
|
}
|
||||||
|
if ticket:
|
||||||
|
view["ticket"] = ticket
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
def _call_vm112(
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
json_body: dict | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
if not VM112_ASSIST_CALL:
|
||||||
|
return None
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if VM112_ASSIST_TOKEN:
|
||||||
|
headers["X-Desk-Assist-Token"] = VM112_ASSIST_TOKEN
|
||||||
|
if session_id:
|
||||||
|
headers["X-Onboarding-Session"] = session_id
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=30.0) as client:
|
||||||
|
r = client.request(method, f"{VM112_ASSIST_API.rstrip('/')}{path}", json=json_body, headers=headers)
|
||||||
|
if r.status_code >= 400:
|
||||||
|
return {"error": r.text[:200], "http_status": r.status_code}
|
||||||
|
return r.json() if r.headers.get("content-type", "").startswith("application/json") else {"ok": True}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions")
|
||||||
|
def list_assist_sessions(user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not DESK_ASSIST_ENABLED:
|
||||||
|
raise HTTPException(503, "assistência desactivada")
|
||||||
|
if not can_read_assist(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
m = _main()
|
||||||
|
with m.db() as conn:
|
||||||
|
funnel = m._funnel_summary(conn, window_hours=48)
|
||||||
|
session_ids = [s["session_id"] for s in funnel.get("active_sessions", []) if s.get("session_id")]
|
||||||
|
assist_map = assist_store.get_assist_state_map(conn, session_ids)
|
||||||
|
sessions = []
|
||||||
|
for item in funnel.get("active_sessions", []):
|
||||||
|
sid = item.get("session_id")
|
||||||
|
if not sid:
|
||||||
|
continue
|
||||||
|
assist = assist_map.get(sid)
|
||||||
|
ticket_row = assist_store.find_ticket_by_session(conn, sid)
|
||||||
|
status = "observing"
|
||||||
|
if assist and assist.get("status") == "active":
|
||||||
|
status = "assisting"
|
||||||
|
elif ticket_row and ticket_row["status"] in ("escalated", "assisting"):
|
||||||
|
status = ticket_row["status"]
|
||||||
|
meta = assist_store.session_funnel_meta(
|
||||||
|
conn, sid, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
|
||||||
|
)
|
||||||
|
sessions.append({
|
||||||
|
**item,
|
||||||
|
"assist_status": status,
|
||||||
|
"assisted_by": assist.get("initiated_by_user") if assist else None,
|
||||||
|
"assigned_to": ticket_row["assigned_to"] if ticket_row else None,
|
||||||
|
"can_escalate": meta.get("can_escalate", False),
|
||||||
|
})
|
||||||
|
return {"sessions": sessions, "window_hours": funnel.get("window_hours", 48)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}")
|
||||||
|
def get_assist_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_read_assist(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
sid = session_id.strip()
|
||||||
|
m = _main()
|
||||||
|
with m.db() as conn:
|
||||||
|
view = _build_session_view(conn, sid, user)
|
||||||
|
timeline = m._session_timeline(conn, sid)
|
||||||
|
from app.funnel_timing import apply_module_timing
|
||||||
|
|
||||||
|
enriched, timing_meta = apply_module_timing(timeline)
|
||||||
|
view["timeline"] = enriched
|
||||||
|
if timing_meta:
|
||||||
|
view["timing"] = timing_meta
|
||||||
|
actions = []
|
||||||
|
if view.get("assist_session_id"):
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT actor, action, payload, created_at
|
||||||
|
FROM assist_actions WHERE assist_session_id = ?
|
||||||
|
ORDER BY id ASC LIMIT 50
|
||||||
|
""",
|
||||||
|
(view["assist_session_id"],),
|
||||||
|
).fetchall()
|
||||||
|
actions = [
|
||||||
|
{
|
||||||
|
"actor": r["actor"],
|
||||||
|
"action": r["action"],
|
||||||
|
"payload": _parse_payload(r["payload"]),
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
view["actions"] = actions
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/escalate")
|
||||||
|
def escalate_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_assist_takeover(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
sid = session_id.strip()
|
||||||
|
now = _now()
|
||||||
|
m = _main()
|
||||||
|
with m.db() as conn:
|
||||||
|
meta = _session_meta(conn, sid)
|
||||||
|
if not meta["can_escalate"]:
|
||||||
|
raise HTTPException(400, "escalada só após validação de domínio")
|
||||||
|
active = assist_store.get_active_assist(conn, sid)
|
||||||
|
open_assist = assist_store.get_open_assist(conn, sid)
|
||||||
|
if active:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
detail={
|
||||||
|
"message": "sessão já em assistência",
|
||||||
|
"assisted_by": active.get("initiated_by_user"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if open_assist and open_assist.get("status") == "escalated" and open_assist.get("initiated_by_user") not in (None, user.username):
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
detail={
|
||||||
|
"message": "sessão já escalada por outro técnico",
|
||||||
|
"assisted_by": open_assist.get("initiated_by_user"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, meta.get("domain"))
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tickets
|
||||||
|
SET status = 'escalated', assigned_to = ?, assigned_at = ?, session_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user.username, now, sid, ticket_id),
|
||||||
|
)
|
||||||
|
if open_assist and open_assist.get("status") == "escalated":
|
||||||
|
assist_id = int(open_assist["id"])
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE assist_sessions SET initiated_by_user = ?, initiated_by = 'technician' WHERE id = ?",
|
||||||
|
(user.username, assist_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO assist_sessions
|
||||||
|
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain, started_at)
|
||||||
|
VALUES (?, ?, 'technician', ?, 'escalated', ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(sid, ticket_id, user.username, meta.get("funnel_stage"), meta.get("domain"), now),
|
||||||
|
)
|
||||||
|
assist_id = int(cur.lastrowid)
|
||||||
|
assist_store.log_action(conn, assist_id, user.username, "escalate", {"source": "desk"})
|
||||||
|
conn.commit()
|
||||||
|
view = _build_session_view(conn, sid, user)
|
||||||
|
return {"status": "escalated", "ticket_id": ticket_id, "session": view}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/takeover")
|
||||||
|
def takeover_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_assist_takeover(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
sid = session_id.strip()
|
||||||
|
now = _now()
|
||||||
|
m = _main()
|
||||||
|
with m.db() as conn:
|
||||||
|
meta = _session_meta(conn, sid)
|
||||||
|
if not meta["can_escalate"]:
|
||||||
|
raise HTTPException(400, "takeover só após validação de domínio")
|
||||||
|
active = assist_store.get_active_assist(conn, sid)
|
||||||
|
open_assist = assist_store.get_open_assist(conn, sid)
|
||||||
|
if active and active.get("initiated_by_user") not in (None, user.username):
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
detail={
|
||||||
|
"message": "sessão já assumida por outro técnico",
|
||||||
|
"assisted_by": active.get("initiated_by_user"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, meta.get("domain"))
|
||||||
|
token_hash = assist_store.hash_token(secrets.token_urlsafe(32))
|
||||||
|
|
||||||
|
if open_assist and open_assist.get("status") == "escalated":
|
||||||
|
assist_id = int(open_assist["id"])
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE assist_sessions
|
||||||
|
SET status = 'active', initiated_by_user = ?, takeover_token_hash = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user.username, token_hash, assist_id),
|
||||||
|
)
|
||||||
|
elif active:
|
||||||
|
assist_id = int(active["id"])
|
||||||
|
else:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO assist_sessions
|
||||||
|
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain,
|
||||||
|
takeover_token_hash, started_at)
|
||||||
|
VALUES (?, ?, 'technician', ?, 'active', ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(sid, ticket_id, user.username, meta.get("funnel_stage"), meta.get("domain"), token_hash, now),
|
||||||
|
)
|
||||||
|
assist_id = int(cur.lastrowid)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tickets
|
||||||
|
SET status = 'assisting', assigned_to = ?, assigned_at = ?, session_id = ?,
|
||||||
|
assist_mode = 'asm', assisted_by = ?, assisted_at = ?, client_paused = 1
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user.username, now, sid, user.username, now, ticket_id),
|
||||||
|
)
|
||||||
|
assist_store.log_action(conn, assist_id, user.username, "takeover", {"phase": "A"})
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
_call_vm112("POST", f"/onboarding/sessions/{sid}/pause", session_id=sid)
|
||||||
|
vm112_result = _call_vm112(
|
||||||
|
"POST",
|
||||||
|
f"/onboarding/sessions/{sid}/takeover",
|
||||||
|
{"technician": user.username},
|
||||||
|
session_id=sid,
|
||||||
|
)
|
||||||
|
if vm112_result and vm112_result.get("takeover_url"):
|
||||||
|
takeover_url = vm112_result["takeover_url"]
|
||||||
|
else:
|
||||||
|
base = os.getenv("VM112_WIZARD_URL", "https://onboard.ibytera.com")
|
||||||
|
takeover_url = f"{base.rstrip('/')}/assist/{sid}?desk=1"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "assisting",
|
||||||
|
"ticket_id": ticket_id,
|
||||||
|
"takeover_url": takeover_url,
|
||||||
|
"client_paused": True,
|
||||||
|
"vm112": vm112_result,
|
||||||
|
"note": "ASM activo — wizard VM112 pausado para o cliente",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/handoff")
|
||||||
|
def handoff_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_assist_handoff(user.role, user.username):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
sid = session_id.strip()
|
||||||
|
now = _now()
|
||||||
|
m = _main()
|
||||||
|
with m.db() as conn:
|
||||||
|
active = assist_store.get_active_assist(conn, sid)
|
||||||
|
if not active:
|
||||||
|
raise HTTPException(404, "nenhuma assistência activa")
|
||||||
|
if user.role == "technician" and active.get("initiated_by_user") not in (None, user.username):
|
||||||
|
raise HTTPException(403, "sessão de outro técnico")
|
||||||
|
ticket_row = assist_store.find_ticket_by_session(conn, sid)
|
||||||
|
ticket_id = int(ticket_row["id"]) if ticket_row else active.get("ticket_id")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE assist_sessions SET status = 'ended', ended_at = ? WHERE id = ?",
|
||||||
|
(now, int(active["id"])),
|
||||||
|
)
|
||||||
|
if ticket_id:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tickets
|
||||||
|
SET status = 'resolved', client_paused = 0, assist_mode = NULL
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(ticket_id,),
|
||||||
|
)
|
||||||
|
assist_store.log_action(conn, int(active["id"]), user.username, "handoff", {})
|
||||||
|
conn.commit()
|
||||||
|
_call_vm112("POST", f"/onboarding/sessions/{sid}/resume", session_id=sid)
|
||||||
|
return {"status": "handoff", "ticket_id": ticket_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/links")
|
||||||
|
def session_external_links(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_read_assist(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
sid = session_id.strip()
|
||||||
|
m = _main()
|
||||||
|
with m.db() as conn:
|
||||||
|
meta = _session_meta(conn, sid)
|
||||||
|
return {
|
||||||
|
"session_id": sid,
|
||||||
|
"domain": meta.get("domain"),
|
||||||
|
"links": assist_catalog.external_links(meta.get("domain")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/actions")
|
||||||
|
def list_session_actions(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_read_assist(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
sid = session_id.strip()
|
||||||
|
m = _main()
|
||||||
|
with m.db() as conn:
|
||||||
|
meta = _session_meta(conn, sid)
|
||||||
|
assist = assist_store.get_active_assist(conn, sid)
|
||||||
|
ticket_row = assist_store.find_ticket_by_session(conn, sid)
|
||||||
|
is_assisting = bool(assist and assist.get("status") == "active") or (
|
||||||
|
ticket_row and ticket_row["status"] == "assisting"
|
||||||
|
)
|
||||||
|
actions = assist_catalog.list_actions_for_session(
|
||||||
|
meta.get("funnel_stage"), is_assisting, user.role
|
||||||
|
)
|
||||||
|
return {"session_id": sid, "actions": actions, "is_assisting": is_assisting}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/actions/{action_id}")
|
||||||
|
def run_session_action(
|
||||||
|
session_id: str,
|
||||||
|
action_id: str,
|
||||||
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
||||||
|
):
|
||||||
|
if not can_assist_takeover(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
sid = session_id.strip()
|
||||||
|
action = action_id.strip()
|
||||||
|
now = _now()
|
||||||
|
m = _main()
|
||||||
|
with m.db() as conn:
|
||||||
|
meta = _session_meta(conn, sid)
|
||||||
|
domain = (meta.get("domain") or "").strip()
|
||||||
|
if not domain:
|
||||||
|
raise HTTPException(400, "domínio não disponível na sessão")
|
||||||
|
assist = assist_store.get_active_assist(conn, sid)
|
||||||
|
open_assist = assist_store.get_open_assist(conn, sid)
|
||||||
|
assist_id = int(assist["id"]) if assist else (int(open_assist["id"]) if open_assist else None)
|
||||||
|
ticket_row = assist_store.find_ticket_by_session(conn, sid)
|
||||||
|
ticket_payload = _parse_payload(ticket_row["payload"]) if ticket_row else {}
|
||||||
|
is_assisting = bool(assist and assist.get("status") == "active")
|
||||||
|
|
||||||
|
if action == assist_catalog.ABORT_ACTION:
|
||||||
|
if user.role not in ("super_admin", "ops_lead"):
|
||||||
|
raise HTTPException(403, "abortar só ops_lead+")
|
||||||
|
if ticket_row:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tickets SET status = 'closed', client_paused = 0, assist_mode = NULL WHERE id = ?",
|
||||||
|
(int(ticket_row["id"]),),
|
||||||
|
)
|
||||||
|
if assist:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE assist_sessions SET status = 'ended', ended_at = ? WHERE id = ?",
|
||||||
|
(now, int(assist["id"])),
|
||||||
|
)
|
||||||
|
if assist_id:
|
||||||
|
assist_store.log_action(conn, assist_id, user.username, f"action.{action}", {"domain": domain})
|
||||||
|
conn.commit()
|
||||||
|
_call_vm112("POST", f"/onboarding/sessions/{sid}/resume", session_id=sid)
|
||||||
|
return {"status": "aborted", "action": action, "session_id": sid}
|
||||||
|
|
||||||
|
if action == assist_catalog.MARK_STEP_ACTION:
|
||||||
|
if not is_assisting:
|
||||||
|
raise HTTPException(400, "acção só em modo assistindo")
|
||||||
|
if assist_id:
|
||||||
|
assist_store.log_action(conn, assist_id, user.username, f"action.{action}", {"domain": domain})
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok", "action": action, "note": "passo marcado no audit log"}
|
||||||
|
|
||||||
|
if action not in assist_catalog.DESK_ACTIONS:
|
||||||
|
raise HTTPException(400, f"acção inválida: {action}")
|
||||||
|
spec = assist_catalog.DESK_ACTIONS[action]
|
||||||
|
if assist_catalog.funnel_rank(meta.get("funnel_stage")) < spec["min_rank"]:
|
||||||
|
raise HTTPException(400, "etapa do funil insuficiente para esta acção")
|
||||||
|
if action not in ("dns.revalidate", "account.retry_sync") and not is_assisting:
|
||||||
|
raise HTTPException(400, "assuma a sessão antes desta acção")
|
||||||
|
|
||||||
|
method, path, body = assist_catalog.build_vm112_request(action, domain, ticket_payload)
|
||||||
|
vm112_result = _call_vm112(method, path, body, session_id=sid)
|
||||||
|
if not assist_id:
|
||||||
|
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, domain)
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO assist_sessions
|
||||||
|
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain, started_at)
|
||||||
|
VALUES (?, ?, 'technician', ?, 'escalated', ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(sid, ticket_id, user.username, meta.get("funnel_stage"), domain, now),
|
||||||
|
)
|
||||||
|
assist_id = int(cur.lastrowid)
|
||||||
|
assist_store.log_action(
|
||||||
|
conn,
|
||||||
|
assist_id,
|
||||||
|
user.username,
|
||||||
|
f"action.{action}",
|
||||||
|
{"domain": domain, "vm112": vm112_result},
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if vm112_result is None and not VM112_ASSIST_CALL:
|
||||||
|
return {
|
||||||
|
"status": "logged",
|
||||||
|
"action": action,
|
||||||
|
"note": "VM112_ASSIST_CALL_VM112=false — acção registada no audit",
|
||||||
|
}
|
||||||
|
if vm112_result and vm112_result.get("http_status", 0) >= 400:
|
||||||
|
raise HTTPException(502, detail={"message": "VM112 rejeitou acção", "vm112": vm112_result})
|
||||||
|
return {"status": "ok", "action": action, "vm112": vm112_result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/technicians/ranking")
|
||||||
|
def technicians_ranking(
|
||||||
|
window_days: int = 30,
|
||||||
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
||||||
|
):
|
||||||
|
if not can_read_assist(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
if user.role == "noc":
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
days = max(1, min(window_days, 365))
|
||||||
|
m = _main()
|
||||||
|
with m.db() as conn:
|
||||||
|
ranking = assist_catalog.technician_ranking(conn, window_days=days)
|
||||||
|
return {"window_days": days, "ranking": ranking, "total": len(ranking)}
|
||||||
|
|
||||||
|
|
||||||
|
def process_assist_started(conn, body, now: str) -> dict:
|
||||||
|
m = _main()
|
||||||
|
sid = (body.session_id or "").strip()
|
||||||
|
technician = (body.data or {}).get("technician")
|
||||||
|
meta = assist_store.session_funnel_meta(
|
||||||
|
conn, sid, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
|
||||||
|
)
|
||||||
|
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, body.domain or meta.get("domain"))
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tickets
|
||||||
|
SET status = 'assisting', session_id = ?, assist_mode = 'asm',
|
||||||
|
assisted_by = ?, assisted_at = ?, client_paused = 1
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(sid, technician, now, ticket_id),
|
||||||
|
)
|
||||||
|
return {"handled": True, "ticket_id": ticket_id, "session_id": sid}
|
||||||
|
|
||||||
|
|
||||||
|
def process_assist_ended(conn, body, now: str) -> dict:
|
||||||
|
sid = (body.session_id or "").strip()
|
||||||
|
ticket_row = assist_store.find_ticket_by_session(conn, sid)
|
||||||
|
if ticket_row:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tickets SET status = 'resolved', client_paused = 0, assist_mode = NULL WHERE id = ?",
|
||||||
|
(int(ticket_row["id"]),),
|
||||||
|
)
|
||||||
|
return {"handled": True, "ticket_id": int(ticket_row["id"]), "session_id": sid}
|
||||||
|
return {"handled": False, "session_id": sid}
|
||||||
|
|
||||||
|
|
||||||
|
def process_escalation_webhook(conn, body, now: str) -> dict:
|
||||||
|
"""Handle onboarding.escalated / onboarding.failed from VM112."""
|
||||||
|
m = _main()
|
||||||
|
sid = (body.session_id or "").strip()
|
||||||
|
if not sid:
|
||||||
|
return {"handled": False, "reason": "missing session_id"}
|
||||||
|
meta = assist_store.session_funnel_meta(
|
||||||
|
conn, sid, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
|
||||||
|
)
|
||||||
|
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, body.domain or meta.get("domain"))
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tickets SET status = 'escalated', session_id = ? WHERE id = ?",
|
||||||
|
(sid, ticket_id),
|
||||||
|
)
|
||||||
|
if not assist_store.get_open_assist(conn, sid):
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO assist_sessions
|
||||||
|
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain, started_at)
|
||||||
|
VALUES (?, ?, 'client', NULL, 'escalated', ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(sid, ticket_id, meta.get("funnel_stage"), body.domain or meta.get("domain"), now),
|
||||||
|
)
|
||||||
|
assist_store.log_action(conn, int(cur.lastrowid), "client", "escalate", {"event": body.event})
|
||||||
|
return {"handled": True, "ticket_id": ticket_id, "session_id": sid}
|
||||||
239
ligbox-ops-platform/api/app/assist_store.py
Normal file
239
ligbox-ops-platform/api/app/assist_store.py
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
"""Assist / takeover session storage — Spec 010 Phase A."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ASSIST_MIN_RANK = 2 # domain.validated
|
||||||
|
ASSIST_MIN_STAGE = "domain_validated"
|
||||||
|
|
||||||
|
TICKET_ASSIST_STATUSES = frozenset({"open", "escalated", "assisting", "resolved", "closed"})
|
||||||
|
TICKET_ACTIVE_STATUSES = frozenset({"open", "escalated", "assisting", "resolved"})
|
||||||
|
|
||||||
|
|
||||||
|
def init_assist_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS assist_sessions (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
ticket_id INTEGER,
|
||||||
|
initiated_by TEXT NOT NULL,
|
||||||
|
initiated_by_user TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
funnel_stage TEXT,
|
||||||
|
domain TEXT,
|
||||||
|
takeover_token_hash TEXT,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
ended_at TEXT,
|
||||||
|
audit_summary TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assist_sessions_session
|
||||||
|
ON assist_sessions(session_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS assist_actions (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
assist_session_id INTEGER NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
payload TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cols = {row[1] for row in conn.execute("PRAGMA table_info(tickets)").fetchall()}
|
||||||
|
for col, ddl in (
|
||||||
|
("session_id", "TEXT"),
|
||||||
|
("assist_mode", "TEXT"),
|
||||||
|
("assisted_by", "TEXT"),
|
||||||
|
("assisted_at", "TEXT"),
|
||||||
|
("client_paused", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
|
):
|
||||||
|
if col not in cols:
|
||||||
|
conn.execute(f"ALTER TABLE tickets ADD COLUMN {col} {ddl}")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_payload(raw: str | None) -> dict:
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def log_action(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
assist_session_id: int,
|
||||||
|
actor: str,
|
||||||
|
action: str,
|
||||||
|
payload: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO assist_actions (assist_session_id, actor, action, payload, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(assist_session_id, actor, action, json.dumps(payload or {}), _now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_assist(conn: sqlite3.Connection, session_id: str) -> dict | None:
|
||||||
|
sid = (session_id or "").strip()
|
||||||
|
if not sid:
|
||||||
|
return None
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM assist_sessions
|
||||||
|
WHERE session_id = ? AND status = 'active' AND ended_at IS NULL
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(sid,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_open_assist(conn: sqlite3.Connection, session_id: str) -> dict | None:
|
||||||
|
"""Sessão de assistência aberta (escalated ou active)."""
|
||||||
|
sid = (session_id or "").strip()
|
||||||
|
if not sid:
|
||||||
|
return None
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM assist_sessions
|
||||||
|
WHERE session_id = ? AND ended_at IS NULL AND status IN ('escalated', 'active')
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(sid,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_assist_state_map(conn: sqlite3.Connection, session_ids: list[str]) -> dict[str, dict]:
|
||||||
|
if not session_ids:
|
||||||
|
return {}
|
||||||
|
placeholders = ",".join("?" * len(session_ids))
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT a.* FROM assist_sessions a
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT session_id, MAX(id) AS max_id
|
||||||
|
FROM assist_sessions
|
||||||
|
WHERE session_id IN ({placeholders}) AND ended_at IS NULL
|
||||||
|
GROUP BY session_id
|
||||||
|
) latest ON a.id = latest.max_id
|
||||||
|
""",
|
||||||
|
session_ids,
|
||||||
|
).fetchall()
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
out[item["session_id"]] = item
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def session_funnel_meta(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
session_id: str,
|
||||||
|
funnel_event_rank: dict[str, int],
|
||||||
|
funnel_stage_by_rank: dict[int, str],
|
||||||
|
onboard_source: str = "vm112-onboard",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
sid = (session_id or "").strip()
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT event_type, payload, created_at
|
||||||
|
FROM webhook_events
|
||||||
|
WHERE source = ?
|
||||||
|
ORDER BY id ASC
|
||||||
|
""",
|
||||||
|
(onboard_source,),
|
||||||
|
).fetchall()
|
||||||
|
max_rank = 0
|
||||||
|
domain = None
|
||||||
|
failed = False
|
||||||
|
last_event_at = None
|
||||||
|
for row in rows:
|
||||||
|
payload = _parse_payload(row["payload"])
|
||||||
|
if (payload.get("session_id") or "").strip() != sid:
|
||||||
|
continue
|
||||||
|
if payload.get("domain"):
|
||||||
|
domain = payload.get("domain")
|
||||||
|
last_event_at = row["created_at"]
|
||||||
|
if row["event_type"] == "onboarding.failed":
|
||||||
|
failed = True
|
||||||
|
max_rank = max(max_rank, 99)
|
||||||
|
else:
|
||||||
|
rank = funnel_event_rank.get(row["event_type"], 0)
|
||||||
|
if not failed:
|
||||||
|
max_rank = max(max_rank, rank)
|
||||||
|
if failed:
|
||||||
|
stage = "failed"
|
||||||
|
else:
|
||||||
|
stage = funnel_stage_by_rank.get(max_rank, "started")
|
||||||
|
return {
|
||||||
|
"session_id": sid,
|
||||||
|
"domain": domain,
|
||||||
|
"funnel_stage": stage,
|
||||||
|
"funnel_rank": max_rank,
|
||||||
|
"can_escalate": max_rank >= ASSIST_MIN_RANK or failed,
|
||||||
|
"last_event_at": last_event_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_ticket_by_session(conn: sqlite3.Connection, session_id: str) -> sqlite3.Row | None:
|
||||||
|
sid = (session_id or "").strip()
|
||||||
|
if not sid:
|
||||||
|
return None
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, status, assigned_to, session_id, payload FROM tickets WHERE session_id = ? ORDER BY id DESC LIMIT 1",
|
||||||
|
(sid,),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return row
|
||||||
|
rows = conn.execute("SELECT id, status, assigned_to, session_id, payload FROM tickets ORDER BY id DESC LIMIT 300").fetchall()
|
||||||
|
for item in rows:
|
||||||
|
payload = _parse_payload(item["payload"])
|
||||||
|
if (payload.get("session_id") or "").strip() == sid:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_onboard_ticket(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
session_id: str,
|
||||||
|
domain: str | None,
|
||||||
|
tenant_id: int = 1,
|
||||||
|
) -> int:
|
||||||
|
existing = find_ticket_by_session(conn, session_id)
|
||||||
|
if existing:
|
||||||
|
return int(existing["id"])
|
||||||
|
now = _now()
|
||||||
|
payload = json.dumps({
|
||||||
|
"event": "onboarding.escalated",
|
||||||
|
"domain": domain,
|
||||||
|
"session_id": session_id,
|
||||||
|
"source": "vm112-onboard",
|
||||||
|
"data": {"reason": "desk_assist"},
|
||||||
|
})
|
||||||
|
subject = f"[assist] {domain or 'sem dominio'} — {session_id[:12]}"
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tickets
|
||||||
|
(tenant_id, subject, status, payload, created_at, session_id, assigned_to, assigned_at)
|
||||||
|
VALUES (?, ?, 'escalated', ?, ?, ?, NULL, NULL)
|
||||||
|
""",
|
||||||
|
(tenant_id, subject, payload, now, session_id),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
508
ligbox-ops-platform/api/app/audit_store.py
Normal file
508
ligbox-ops-platform/api/app/audit_store.py
Normal file
|
|
@ -0,0 +1,508 @@
|
||||||
|
"""SQLite persistence for audit domains and checks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.collectors.base import CHECK_LABELS
|
||||||
|
|
||||||
|
ONBOARD_DOMAIN_EVENTS = frozenset({"account.created", "onboarding.completed"})
|
||||||
|
TENANT_ONBOARD = 1
|
||||||
|
|
||||||
|
TENANT_WEBHOOK_SOURCE = {
|
||||||
|
1: "vm112-onboard",
|
||||||
|
2: "wazuh",
|
||||||
|
}
|
||||||
|
|
||||||
|
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_STAGE_LABELS = {
|
||||||
|
"started": "Iniciado",
|
||||||
|
"domain_validated": "Domínio OK",
|
||||||
|
"dns_applied": "DNS aplicado",
|
||||||
|
"account_created": "Conta criada",
|
||||||
|
"infra_synced": "Infra sync",
|
||||||
|
"completed": "Concluído",
|
||||||
|
"company_validated": "Empresa validada",
|
||||||
|
"webmail_released": "Webmail liberado",
|
||||||
|
"failed": "Falhou",
|
||||||
|
"registered": "Registado",
|
||||||
|
"unknown": "Sem dados",
|
||||||
|
}
|
||||||
|
|
||||||
|
STATUS_RANK = {"pass": 0, "skip": 1, "warn": 2, "error": 3, "fail": 4}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_payload(raw: str | None) -> dict:
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def init_audit_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_domains (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'onboarding',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(tenant_id, domain)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_checks (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
check_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
evidence TEXT,
|
||||||
|
checked_at TEXT NOT NULL,
|
||||||
|
UNIQUE(tenant_id, domain, check_id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_domains_from_webhooks(conn: sqlite3.Connection) -> int:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT event_type, payload FROM webhook_events
|
||||||
|
WHERE source = 'vm112-onboard'
|
||||||
|
ORDER BY id DESC LIMIT 500
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
added = 0
|
||||||
|
now = _now()
|
||||||
|
seen: set[tuple[int, str]] = set()
|
||||||
|
for row in rows:
|
||||||
|
if row["event_type"] not in ONBOARD_DOMAIN_EVENTS:
|
||||||
|
continue
|
||||||
|
payload = _parse_payload(row["payload"])
|
||||||
|
domain = (payload.get("domain") or "").strip().lower()
|
||||||
|
if not domain or len(domain) < 3:
|
||||||
|
continue
|
||||||
|
key = (TENANT_ONBOARD, domain)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO audit_domains (tenant_id, domain, source, created_at)
|
||||||
|
VALUES (?, ?, 'onboarding', ?)
|
||||||
|
""",
|
||||||
|
(TENANT_ONBOARD, domain, now),
|
||||||
|
)
|
||||||
|
if cur.rowcount:
|
||||||
|
added += 1
|
||||||
|
conn.commit()
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
def list_audit_domains(conn: sqlite3.Connection, tenant_id: int | None = None) -> list[dict]:
|
||||||
|
if tenant_id:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT tenant_id, domain, source, created_at FROM audit_domains WHERE tenant_id = ? ORDER BY domain",
|
||||||
|
(tenant_id,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT tenant_id, domain, source, created_at FROM audit_domains ORDER BY tenant_id, domain"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_check(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
tenant_id: int,
|
||||||
|
domain: str,
|
||||||
|
check_id: str,
|
||||||
|
status: str,
|
||||||
|
message: str,
|
||||||
|
evidence: dict | None,
|
||||||
|
checked_at: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_checks (tenant_id, domain, check_id, status, message, evidence, checked_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(tenant_id, domain, check_id) DO UPDATE SET
|
||||||
|
status = excluded.status,
|
||||||
|
message = excluded.message,
|
||||||
|
evidence = excluded.evidence,
|
||||||
|
checked_at = excluded.checked_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
tenant_id,
|
||||||
|
domain.lower(),
|
||||||
|
check_id,
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
json.dumps(evidence or {}),
|
||||||
|
checked_at or _now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_checks(conn: sqlite3.Connection, tenant_id: int, domain: str) -> list[dict]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT check_id, status, message, evidence, checked_at
|
||||||
|
FROM audit_checks WHERE tenant_id = ? AND domain = ?
|
||||||
|
ORDER BY check_id
|
||||||
|
""",
|
||||||
|
(tenant_id, domain.lower()),
|
||||||
|
).fetchall()
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
item["label"] = CHECK_LABELS.get(item["check_id"], item["check_id"])
|
||||||
|
item["evidence"] = _parse_payload(item.get("evidence"))
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_score(checks: list[dict]) -> dict[str, Any]:
|
||||||
|
total = len(CHECK_LABELS)
|
||||||
|
counts = {"pass": 0, "warn": 0, "fail": 0, "error": 0, "skip": 0}
|
||||||
|
worst = "pass"
|
||||||
|
for c in checks:
|
||||||
|
st = c.get("status") or "skip"
|
||||||
|
counts[st] = counts.get(st, 0) + 1
|
||||||
|
if STATUS_RANK.get(st, 0) > STATUS_RANK.get(worst, 0):
|
||||||
|
worst = st
|
||||||
|
if worst in ("fail", "error"):
|
||||||
|
overall = "critical"
|
||||||
|
elif worst == "warn":
|
||||||
|
overall = "degraded"
|
||||||
|
elif checks:
|
||||||
|
overall = "healthy"
|
||||||
|
else:
|
||||||
|
overall = "unknown"
|
||||||
|
return {
|
||||||
|
"pass": counts.get("pass", 0),
|
||||||
|
"warn": counts.get("warn", 0),
|
||||||
|
"fail": counts.get("fail", 0),
|
||||||
|
"error": counts.get("error", 0),
|
||||||
|
"skip": counts.get("skip", 0),
|
||||||
|
"total": total,
|
||||||
|
"overall_status": overall,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_overview(conn: sqlite3.Connection, tenant_id: int, name: str, ip: str) -> dict:
|
||||||
|
if tenant_id == 2:
|
||||||
|
from app.modules import store as module_store
|
||||||
|
|
||||||
|
if module_store.is_module_enabled("wazuh-soc"):
|
||||||
|
from app.wazuh_soc_store import wazuh_tenant_overview
|
||||||
|
|
||||||
|
return wazuh_tenant_overview(conn, tenant_id, name, ip)
|
||||||
|
domains = list_audit_domains(conn, tenant_id)
|
||||||
|
if not domains:
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"name": name,
|
||||||
|
"ip": ip,
|
||||||
|
"status": "unknown",
|
||||||
|
"score": {"pass": 0, "warn": 0, "fail": 0, "total": 8},
|
||||||
|
"domains_count": 0,
|
||||||
|
"last_audit_at": None,
|
||||||
|
"top_issues": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
all_checks: list[dict] = []
|
||||||
|
last_audit = None
|
||||||
|
top_issues: list[dict] = []
|
||||||
|
domain_scores: list[dict] = []
|
||||||
|
for d in domains:
|
||||||
|
checks = get_checks(conn, tenant_id, d["domain"])
|
||||||
|
if not checks:
|
||||||
|
continue
|
||||||
|
all_checks.extend(checks)
|
||||||
|
domain_scores.append(aggregate_score(checks))
|
||||||
|
for c in checks:
|
||||||
|
if c["checked_at"] and (not last_audit or c["checked_at"] > last_audit):
|
||||||
|
last_audit = c["checked_at"]
|
||||||
|
if c["status"] in ("fail", "error", "warn"):
|
||||||
|
top_issues.append({
|
||||||
|
"domain": d["domain"],
|
||||||
|
"check_id": c["check_id"],
|
||||||
|
"status": c["status"],
|
||||||
|
"message": c.get("message"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if domain_scores:
|
||||||
|
worst = max(domain_scores, key=lambda s: STATUS_RANK.get(s["overall_status"], 0))
|
||||||
|
score = worst
|
||||||
|
else:
|
||||||
|
score = aggregate_score(all_checks)
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"name": name,
|
||||||
|
"ip": ip,
|
||||||
|
"status": score["overall_status"],
|
||||||
|
"score": {
|
||||||
|
"pass": score["pass"],
|
||||||
|
"warn": score["warn"],
|
||||||
|
"fail": score["fail"] + score["error"],
|
||||||
|
"total": score["total"],
|
||||||
|
},
|
||||||
|
"domains_count": len(domains),
|
||||||
|
"last_audit_at": last_audit,
|
||||||
|
"top_issues": top_issues[:5],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_overview(conn: sqlite3.Connection) -> dict:
|
||||||
|
tenants = conn.execute("SELECT id, name, ip FROM tenants ORDER BY id").fetchall()
|
||||||
|
return {
|
||||||
|
"generated_at": _now(),
|
||||||
|
"tenants": [tenant_overview(conn, t["id"], t["name"], t["ip"]) for t in tenants],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scorecard(conn: sqlite3.Connection, tenant_id: int, domain: str) -> dict:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
checks = get_checks(conn, tenant_id, domain)
|
||||||
|
score = aggregate_score(checks)
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"domain": domain,
|
||||||
|
"checked_at": max((c["checked_at"] for c in checks), default=None),
|
||||||
|
"overall_status": score["overall_status"],
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_client_ip(payload: dict, data: dict | None = None) -> str | None:
|
||||||
|
data = data or {}
|
||||||
|
for key in ("client_ip", "user_ip", "remote_ip", "srcip", "ip", "agent_ip"):
|
||||||
|
val = data.get(key) or payload.get(key)
|
||||||
|
if val:
|
||||||
|
return str(val)
|
||||||
|
ingress = payload.get("ingress_client_ip")
|
||||||
|
return str(ingress) if ingress else None
|
||||||
|
|
||||||
|
|
||||||
|
def _funnel_stage_from_events(events: list[dict]) -> str:
|
||||||
|
best_rank = 0
|
||||||
|
for ev in events:
|
||||||
|
rank = FUNNEL_EVENT_RANK.get(ev.get("event") or "", 0)
|
||||||
|
if rank > best_rank:
|
||||||
|
best_rank = rank
|
||||||
|
if best_rank:
|
||||||
|
return FUNNEL_STAGE_BY_RANK.get(best_rank, "unknown")
|
||||||
|
return "registered"
|
||||||
|
|
||||||
|
|
||||||
|
def _execution_status(events: list[dict]) -> str:
|
||||||
|
types = {ev.get("event") for ev in events}
|
||||||
|
if "onboarding.failed" in types:
|
||||||
|
return "failed"
|
||||||
|
if "onboarding.completed" in types:
|
||||||
|
return "completed"
|
||||||
|
if types & set(FUNNEL_EVENT_RANK):
|
||||||
|
return "in_progress"
|
||||||
|
if events:
|
||||||
|
return "in_progress"
|
||||||
|
return "registered"
|
||||||
|
|
||||||
|
|
||||||
|
def _tickets_for_domain(conn: sqlite3.Connection, domain: str) -> list[dict]:
|
||||||
|
dom = domain.lower().strip()
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, subject, status, session_id, payload, created_at
|
||||||
|
FROM tickets ORDER BY id DESC LIMIT 500
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
payload = _parse_payload(row["payload"])
|
||||||
|
if (payload.get("domain") or "").strip().lower() != dom:
|
||||||
|
continue
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
out.append({
|
||||||
|
"ticket_id": row["id"],
|
||||||
|
"status": row["status"],
|
||||||
|
"subject": row["subject"],
|
||||||
|
"session_id": row["session_id"] or payload.get("session_id"),
|
||||||
|
"email": data.get("email") or payload.get("account_email"),
|
||||||
|
"crm_track": payload.get("crm_track"),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_webhook_events(conn: sqlite3.Connection, source: str | None, domain: str) -> list[dict]:
|
||||||
|
if not source:
|
||||||
|
return []
|
||||||
|
dom = domain.lower().strip()
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT event_type, payload, created_at FROM webhook_events
|
||||||
|
WHERE source = ?
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
""",
|
||||||
|
(source,),
|
||||||
|
).fetchall()
|
||||||
|
events = []
|
||||||
|
for row in rows:
|
||||||
|
payload = _parse_payload(row["payload"])
|
||||||
|
if (payload.get("domain") or "").strip().lower() != dom:
|
||||||
|
continue
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
client_ip = _extract_client_ip(payload, data)
|
||||||
|
detail = data.get("step") or data.get("description") or data.get("agent")
|
||||||
|
if source == "wazuh" and not client_ip:
|
||||||
|
client_ip = data.get("agent_ip") or data.get("srcip")
|
||||||
|
events.append({
|
||||||
|
"event": row["event_type"],
|
||||||
|
"at": row["created_at"],
|
||||||
|
"session_id": payload.get("session_id"),
|
||||||
|
"email": data.get("email"),
|
||||||
|
"client_ip": client_ip,
|
||||||
|
"detail": detail,
|
||||||
|
})
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_detail(conn: sqlite3.Connection, tenant_id: int, domain_row: dict) -> dict:
|
||||||
|
domain = domain_row["domain"]
|
||||||
|
checks = get_checks(conn, tenant_id, domain)
|
||||||
|
score = aggregate_score(checks)
|
||||||
|
issues = [
|
||||||
|
{
|
||||||
|
"check_id": c["check_id"],
|
||||||
|
"label": c.get("label") or CHECK_LABELS.get(c["check_id"], c["check_id"]),
|
||||||
|
"status": c["status"],
|
||||||
|
"message": c.get("message"),
|
||||||
|
"checked_at": c.get("checked_at"),
|
||||||
|
"evidence": c.get("evidence") or {},
|
||||||
|
}
|
||||||
|
for c in checks
|
||||||
|
if c.get("status") in ("fail", "error", "warn")
|
||||||
|
]
|
||||||
|
source = TENANT_WEBHOOK_SOURCE.get(tenant_id)
|
||||||
|
timeline = _domain_webhook_events(conn, source, domain)
|
||||||
|
tickets = _tickets_for_domain(conn, domain)
|
||||||
|
ticket = tickets[0] if tickets else None
|
||||||
|
funnel_stage = _funnel_stage_from_events(timeline)
|
||||||
|
execution_status = _execution_status(timeline)
|
||||||
|
client_ips = sorted({ev["client_ip"] for ev in timeline if ev.get("client_ip")})
|
||||||
|
last_event = timeline[-1] if timeline else None
|
||||||
|
started_at = timeline[0]["at"] if timeline else domain_row.get("created_at")
|
||||||
|
return {
|
||||||
|
"domain": domain,
|
||||||
|
"source": domain_row.get("source"),
|
||||||
|
"registered_at": domain_row.get("created_at"),
|
||||||
|
"email": (last_event or {}).get("email") or (ticket or {}).get("email"),
|
||||||
|
"session_id": (last_event or {}).get("session_id") or (ticket or {}).get("session_id"),
|
||||||
|
"client_ip": client_ips[-1] if client_ips else None,
|
||||||
|
"client_ips": client_ips,
|
||||||
|
"funnel_stage": funnel_stage,
|
||||||
|
"funnel_stage_label": FUNNEL_STAGE_LABELS.get(funnel_stage, funnel_stage),
|
||||||
|
"execution_status": execution_status,
|
||||||
|
"last_event": (last_event or {}).get("event"),
|
||||||
|
"last_event_at": (last_event or {}).get("at"),
|
||||||
|
"started_at": started_at,
|
||||||
|
"audit_status": score["overall_status"],
|
||||||
|
"score": {
|
||||||
|
"pass": score["pass"],
|
||||||
|
"warn": score["warn"],
|
||||||
|
"fail": score["fail"] + score["error"],
|
||||||
|
"total": score["total"],
|
||||||
|
},
|
||||||
|
"issue_count": len(issues),
|
||||||
|
"issues": issues,
|
||||||
|
"ticket_id": (ticket or {}).get("ticket_id"),
|
||||||
|
"ticket_status": (ticket or {}).get("status"),
|
||||||
|
"tickets_count": len(tickets),
|
||||||
|
"timeline": timeline,
|
||||||
|
"last_audit_at": max((c["checked_at"] for c in checks), default=None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_funnel_timing_to_domains(domain_details: list[dict]) -> None:
|
||||||
|
from app.funnel_timing import apply_module_timing
|
||||||
|
|
||||||
|
for domain in domain_details:
|
||||||
|
timeline = domain.get("timeline") or []
|
||||||
|
if not timeline:
|
||||||
|
continue
|
||||||
|
enriched, timing_meta = apply_module_timing(timeline)
|
||||||
|
domain["timeline"] = enriched
|
||||||
|
if timing_meta:
|
||||||
|
domain["timing"] = timing_meta
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_details(conn: sqlite3.Connection, tenant_id: int) -> dict | None:
|
||||||
|
row = conn.execute("SELECT id, name, ip FROM tenants WHERE id = ?", (tenant_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
if tenant_id == 2:
|
||||||
|
from app.modules import store as module_store
|
||||||
|
|
||||||
|
if module_store.is_module_enabled("wazuh-soc"):
|
||||||
|
from app.wazuh_soc_store import wazuh_tenant_details
|
||||||
|
|
||||||
|
return wazuh_tenant_details(conn, tenant_id, row["name"], row["ip"])
|
||||||
|
domains = list_audit_domains(conn, tenant_id)
|
||||||
|
domain_details = [_domain_detail(conn, tenant_id, d) for d in domains]
|
||||||
|
_apply_funnel_timing_to_domains(domain_details)
|
||||||
|
summary = {
|
||||||
|
"domains_total": len(domain_details),
|
||||||
|
"in_progress": sum(1 for d in domain_details if d["execution_status"] == "in_progress"),
|
||||||
|
"completed": sum(1 for d in domain_details if d["execution_status"] == "completed"),
|
||||||
|
"failed": sum(1 for d in domain_details if d["execution_status"] == "failed"),
|
||||||
|
"registered": sum(1 for d in domain_details if d["execution_status"] == "registered"),
|
||||||
|
"with_issues": sum(1 for d in domain_details if d["issue_count"] > 0),
|
||||||
|
}
|
||||||
|
result = {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"name": row["name"],
|
||||||
|
"ip": row["ip"],
|
||||||
|
"generated_at": _now(),
|
||||||
|
"summary": summary,
|
||||||
|
"domains": domain_details,
|
||||||
|
}
|
||||||
|
if tenant_id == 1:
|
||||||
|
from app.modules import store as module_store
|
||||||
|
|
||||||
|
if module_store.is_module_enabled("wizard-security"):
|
||||||
|
from app import security_store
|
||||||
|
|
||||||
|
result["security"] = security_store.build_summary(conn, window_hours=24)
|
||||||
|
return result
|
||||||
380
ligbox-ops-platform/api/app/auth.py
Normal file
380
ligbox-ops-platform/api/app/auth.py
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
"""Authentication and JWT for Ligbox Ops Desk."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, Request
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from app.totp_util import verify_code as verify_totp_code
|
||||||
|
|
||||||
|
DB_PATH = Path(os.getenv("SQLITE_PATH", "/data/ops.db"))
|
||||||
|
JWT_SECRET = os.getenv("JWT_SECRET", "ligbox-ops-change-me-in-production")
|
||||||
|
JWT_ALGORITHM = "HS256"
|
||||||
|
JWT_EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "8"))
|
||||||
|
DESK_AUTH_ENABLED = os.getenv("DESK_AUTH_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||||
|
DESK_BOOTSTRAP_PASSWORD = os.getenv("DESK_BOOTSTRAP_PASSWORD", "805353")
|
||||||
|
AUTH_LOGIN_RATE_LIMIT = int(os.getenv("AUTH_LOGIN_RATE_LIMIT", "5"))
|
||||||
|
OPS_INTERNAL_TOKEN = os.getenv("OPS_INTERNAL_TOKEN", "")
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
_login_attempts: dict[str, list[float]] = {}
|
||||||
|
_mfa_pending: dict[str, tuple[str, float]] = {}
|
||||||
|
MFA_TOKEN_TTL_SEC = 300
|
||||||
|
|
||||||
|
SEED_USERS = (
|
||||||
|
("root", "super_admin", "Roger"),
|
||||||
|
("admin", "ops_lead", "Chefe Ops"),
|
||||||
|
("mini", "technician", "Suporte"),
|
||||||
|
("noc", "noc", "NOC"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeskUser:
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
display_name: str | None = None
|
||||||
|
active: bool = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_authenticated(self) -> bool:
|
||||||
|
return bool(self.username)
|
||||||
|
|
||||||
|
|
||||||
|
def db() -> sqlite3.Connection:
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def init_auth_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
from app import backup_codes, mfa_recovery_store, registration_store
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS desk_users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_login_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
registration_store.init_registration_schema(conn)
|
||||||
|
backup_codes.init_backup_schema(conn)
|
||||||
|
mfa_recovery_store.init_recovery_schema(conn)
|
||||||
|
cols = {row[1] for row in conn.execute("PRAGMA table_info(tickets)").fetchall()}
|
||||||
|
if "assigned_to" not in cols:
|
||||||
|
conn.execute("ALTER TABLE tickets ADD COLUMN assigned_to TEXT")
|
||||||
|
if "assigned_at" not in cols:
|
||||||
|
conn.execute("ALTER TABLE tickets ADD COLUMN assigned_at TEXT")
|
||||||
|
|
||||||
|
count = conn.execute("SELECT COUNT(*) c FROM desk_users").fetchone()["c"]
|
||||||
|
if count == 0:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
for username, role, display_name in SEED_USERS:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO desk_users
|
||||||
|
(username, password_hash, role, display_name, active, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, 1, ?, ?)
|
||||||
|
""",
|
||||||
|
(username, hash_password(DESK_BOOTSTRAP_PASSWORD), role, display_name, now, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user: DeskUser) -> tuple[str, int]:
|
||||||
|
expires = timedelta(hours=JWT_EXPIRE_HOURS)
|
||||||
|
expire_at = datetime.now(timezone.utc) + expires
|
||||||
|
payload = {
|
||||||
|
"sub": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"exp": expire_at,
|
||||||
|
"iat": datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||||
|
return token, int(expires.total_seconds())
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> DeskUser:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||||
|
except JWTError as exc:
|
||||||
|
raise HTTPException(401, "invalid or expired token") from exc
|
||||||
|
username = payload.get("sub")
|
||||||
|
role = payload.get("role")
|
||||||
|
if not username or role not in {"super_admin", "ops_lead", "technician", "noc"}:
|
||||||
|
raise HTTPException(401, "invalid token claims")
|
||||||
|
with db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT username, role, display_name, active FROM desk_users WHERE username = ?",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
if not row or not row["active"]:
|
||||||
|
raise HTTPException(401, "user inactive or not found")
|
||||||
|
return DeskUser(
|
||||||
|
username=row["username"],
|
||||||
|
role=row["role"],
|
||||||
|
display_name=row["display_name"],
|
||||||
|
active=bool(row["active"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_login(username: str) -> str:
|
||||||
|
u = username.strip()
|
||||||
|
if u.lower() == "root":
|
||||||
|
return "root"
|
||||||
|
if "@" in u:
|
||||||
|
return u.lower()
|
||||||
|
return u.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _user_row(login: str) -> sqlite3.Row | None:
|
||||||
|
with db() as conn:
|
||||||
|
return conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT username, password_hash, role, display_name, active, totp_secret, totp_enabled
|
||||||
|
FROM desk_users WHERE username = ? OR email = ?
|
||||||
|
""",
|
||||||
|
(login, login),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def check_credentials(username: str, password: str) -> tuple[DeskUser | None, sqlite3.Row | None]:
|
||||||
|
login = _normalize_login(username)
|
||||||
|
row = _user_row(login)
|
||||||
|
if not row or not row["active"]:
|
||||||
|
return None, None
|
||||||
|
if not verify_password(password, row["password_hash"]):
|
||||||
|
return None, None
|
||||||
|
user = DeskUser(
|
||||||
|
username=row["username"],
|
||||||
|
role=row["role"],
|
||||||
|
display_name=row["display_name"],
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
return user, row
|
||||||
|
|
||||||
|
|
||||||
|
def touch_last_login(username: str) -> None:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE desk_users SET last_login_at = ?, updated_at = ? WHERE username = ?",
|
||||||
|
(now, now, username),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_user(username: str, password: str) -> DeskUser | None:
|
||||||
|
user, _row = check_credentials(username, password)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
touch_last_login(user.username)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def user_requires_totp(row: sqlite3.Row | None) -> bool:
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
return bool(row["totp_enabled"] and row["totp_secret"])
|
||||||
|
|
||||||
|
|
||||||
|
def create_mfa_token(username: str) -> str:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
_mfa_pending[token] = (username, time.time() + MFA_TOKEN_TTL_SEC)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def peek_mfa_token(token: str) -> str | None:
|
||||||
|
entry = _mfa_pending.get(token)
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
username, expires = entry
|
||||||
|
if time.time() > expires:
|
||||||
|
_mfa_pending.pop(token, None)
|
||||||
|
return None
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
def consume_mfa_token(token: str) -> str | None:
|
||||||
|
entry = _mfa_pending.pop(token, None)
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
username, expires = entry
|
||||||
|
if time.time() > expires:
|
||||||
|
return None
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
def verify_user_totp(username: str, code: str) -> bool:
|
||||||
|
row = _user_row(username)
|
||||||
|
if not row or not row["totp_secret"]:
|
||||||
|
return False
|
||||||
|
return verify_totp_code(row["totp_secret"], code)
|
||||||
|
|
||||||
|
|
||||||
|
def check_login_rate_limit(client_ip: str) -> None:
|
||||||
|
now = time.time()
|
||||||
|
window = 60.0
|
||||||
|
attempts = _login_attempts.setdefault(client_ip, [])
|
||||||
|
attempts[:] = [t for t in attempts if now - t < window]
|
||||||
|
if len(attempts) >= AUTH_LOGIN_RATE_LIMIT:
|
||||||
|
raise HTTPException(429, "too many login attempts")
|
||||||
|
attempts.append(now)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bearer(authorization: str | None) -> str | None:
|
||||||
|
if not authorization:
|
||||||
|
return None
|
||||||
|
parts = authorization.split(" ", 1)
|
||||||
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||||
|
return None
|
||||||
|
return parts[1].strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user_optional(
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> DeskUser | None:
|
||||||
|
if not DESK_AUTH_ENABLED:
|
||||||
|
return DeskUser(username="system", role="super_admin", display_name="Auth disabled")
|
||||||
|
token = _extract_bearer(authorization)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return decode_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(user: DeskUser | None = Depends(get_current_user_optional)) -> DeskUser:
|
||||||
|
if not DESK_AUTH_ENABLED:
|
||||||
|
return DeskUser(username="system", role="super_admin", display_name="Auth disabled")
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(401, "not authenticated")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_internal_or_user(
|
||||||
|
request: Request,
|
||||||
|
x_ops_internal_token: str | None = Header(default=None),
|
||||||
|
user: DeskUser | None = Depends(get_current_user_optional),
|
||||||
|
) -> DeskUser:
|
||||||
|
if OPS_INTERNAL_TOKEN and x_ops_internal_token == OPS_INTERNAL_TOKEN:
|
||||||
|
return DeskUser(username="worker", role="super_admin", display_name="Internal")
|
||||||
|
if not DESK_AUTH_ENABLED:
|
||||||
|
return DeskUser(username="system", role="super_admin", display_name="Auth disabled")
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(401, "not authenticated")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_roles(*roles: str):
|
||||||
|
allowed = frozenset(roles)
|
||||||
|
|
||||||
|
def dependency(user: DeskUser = Depends(get_current_user)) -> DeskUser:
|
||||||
|
if user.role not in allowed:
|
||||||
|
raise HTTPException(403, "insufficient permissions")
|
||||||
|
return user
|
||||||
|
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
|
def mask_value(value: Any) -> Any:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
return "***" if value else value
|
||||||
|
return "***"
|
||||||
|
|
||||||
|
|
||||||
|
def mask_company_profile(profile: dict | None) -> dict | None:
|
||||||
|
if not profile:
|
||||||
|
return profile
|
||||||
|
masked = dict(profile)
|
||||||
|
for key in ("tax_id", "email_billing", "email_legal", "phone_landline", "phone_mobile", "contact_phone"):
|
||||||
|
if key in masked:
|
||||||
|
masked[key] = "***"
|
||||||
|
if "address" in masked:
|
||||||
|
masked["address"] = {}
|
||||||
|
return masked
|
||||||
|
|
||||||
|
|
||||||
|
def mask_ticket(ticket: dict) -> dict:
|
||||||
|
out = dict(ticket)
|
||||||
|
out["company_profile"] = mask_company_profile(out.get("company_profile"))
|
||||||
|
out.pop("billing_state", None)
|
||||||
|
payload = out.get("payload")
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
payload = dict(payload)
|
||||||
|
payload.pop("billing_state", None)
|
||||||
|
if payload.get("company_profile"):
|
||||||
|
payload["company_profile"] = mask_company_profile(payload["company_profile"])
|
||||||
|
notes = payload.get("funnel_notes")
|
||||||
|
if isinstance(notes, list):
|
||||||
|
payload["funnel_notes"] = [
|
||||||
|
{
|
||||||
|
**note,
|
||||||
|
"data": {
|
||||||
|
**(note.get("data") or {}),
|
||||||
|
"company_profile": mask_company_profile((note.get("data") or {}).get("company_profile")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for note in notes
|
||||||
|
]
|
||||||
|
out["payload"] = payload
|
||||||
|
out.pop("email", None)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def mask_summary_for_noc(summary: dict) -> dict:
|
||||||
|
out = dict(summary)
|
||||||
|
out["recent_tickets"] = [mask_ticket(t) for t in out.get("recent_tickets", [])]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def user_public_dict(row: sqlite3.Row | dict) -> dict:
|
||||||
|
if isinstance(row, sqlite3.Row):
|
||||||
|
row = dict(row)
|
||||||
|
out = {
|
||||||
|
"username": row["username"],
|
||||||
|
"role": row["role"],
|
||||||
|
"display_name": row.get("display_name"),
|
||||||
|
"active": bool(row.get("active", 1)),
|
||||||
|
"last_login_at": row.get("last_login_at"),
|
||||||
|
"created_at": row.get("created_at"),
|
||||||
|
"updated_at": row.get("updated_at"),
|
||||||
|
}
|
||||||
|
if row.get("email"):
|
||||||
|
out["email"] = row["email"]
|
||||||
|
if row.get("phone"):
|
||||||
|
out["phone"] = row["phone"]
|
||||||
|
if "mfa_enabled" in row:
|
||||||
|
out["mfa_enabled"] = bool(row.get("mfa_enabled"))
|
||||||
|
if "totp_enabled" in row:
|
||||||
|
out["totp_enabled"] = bool(row.get("totp_enabled"))
|
||||||
|
if "backup_codes_remaining" in row:
|
||||||
|
out["backup_codes_remaining"] = int(row.get("backup_codes_remaining") or 0)
|
||||||
|
return out
|
||||||
279
ligbox-ops-platform/api/app/auth_routes.py
Normal file
279
ligbox-ops-platform/api/app/auth_routes.py
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
"""Auth API routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
|
|
||||||
|
from app import auth, backup_codes, mail_notify
|
||||||
|
from app.permissions import ROLES, can_manage_users
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginMfaRequest(BaseModel):
|
||||||
|
mfa_token: str
|
||||||
|
totp_code: str | None = Field(default=None, min_length=6, max_length=6)
|
||||||
|
backup_code: str | None = Field(default=None, min_length=8, max_length=12)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_one_factor(self):
|
||||||
|
has_totp = bool(self.totp_code and self.totp_code.strip())
|
||||||
|
has_backup = bool(self.backup_code and self.backup_code.strip())
|
||||||
|
if has_totp == has_backup:
|
||||||
|
raise ValueError("informe o código 2FA ou um código de backup")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdateRequest(BaseModel):
|
||||||
|
role: str | None = None
|
||||||
|
active: bool | None = None
|
||||||
|
password: str | None = Field(default=None, min_length=6)
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: str = Field(min_length=1)
|
||||||
|
new_password: str = Field(min_length=8)
|
||||||
|
totp_code: str | None = Field(default=None, min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def login(body: LoginRequest, request: Request):
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
auth.check_login_rate_limit(client_ip)
|
||||||
|
user, row = auth.check_credentials(body.username, body.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "invalid credentials")
|
||||||
|
if auth.user_requires_totp(row):
|
||||||
|
mfa_token = auth.create_mfa_token(user.username)
|
||||||
|
return {
|
||||||
|
"mfa_required": True,
|
||||||
|
"mfa_token": mfa_token,
|
||||||
|
"expires_in": auth.MFA_TOKEN_TTL_SEC,
|
||||||
|
"username": user.username,
|
||||||
|
}
|
||||||
|
auth.touch_last_login(user.username)
|
||||||
|
token, expires_in = auth.create_access_token(user)
|
||||||
|
return {
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": expires_in,
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login/mfa")
|
||||||
|
def login_mfa(body: LoginMfaRequest, request: Request):
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
auth.check_login_rate_limit(client_ip)
|
||||||
|
username = auth.consume_mfa_token(body.mfa_token)
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(401, "invalid or expired mfa session")
|
||||||
|
if body.backup_code:
|
||||||
|
with auth.db() as conn:
|
||||||
|
ok = backup_codes.consume_backup_code(conn, username, body.backup_code.strip())
|
||||||
|
conn.commit()
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(401, "código de backup inválido ou já utilizado")
|
||||||
|
elif not auth.verify_user_totp(username, body.totp_code or ""):
|
||||||
|
raise HTTPException(401, "invalid authenticator code")
|
||||||
|
row = auth._user_row(username)
|
||||||
|
if not row or not row["active"]:
|
||||||
|
raise HTTPException(401, "user inactive")
|
||||||
|
user = auth.DeskUser(
|
||||||
|
username=row["username"],
|
||||||
|
role=row["role"],
|
||||||
|
display_name=row["display_name"],
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
auth.touch_last_login(user.username)
|
||||||
|
token, expires_in = auth.create_access_token(user)
|
||||||
|
return {
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": expires_in,
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
return {"ok": True, "username": user.username}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def me(user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
with auth.db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT username, role, display_name, active, last_login_at, created_at, updated_at,
|
||||||
|
email, phone, mfa_enabled, totp_enabled
|
||||||
|
FROM desk_users WHERE username = ?
|
||||||
|
""",
|
||||||
|
(user.username,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "user not found")
|
||||||
|
out = auth.user_public_dict(row)
|
||||||
|
with auth.db() as conn:
|
||||||
|
out["backup_codes_remaining"] = backup_codes.count_remaining(conn, user.username)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
def change_password(
|
||||||
|
body: ChangePasswordRequest,
|
||||||
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
||||||
|
):
|
||||||
|
row = auth._user_row(user.username)
|
||||||
|
if not row or not row["active"]:
|
||||||
|
raise HTTPException(401, "user inactive or not found")
|
||||||
|
if not auth.verify_password(body.current_password, row["password_hash"]):
|
||||||
|
raise HTTPException(401, "senha atual incorreta")
|
||||||
|
if body.current_password == body.new_password:
|
||||||
|
raise HTTPException(400, "a nova senha deve ser diferente da atual")
|
||||||
|
if auth.user_requires_totp(row):
|
||||||
|
code = (body.totp_code or "").strip()
|
||||||
|
if not code:
|
||||||
|
raise HTTPException(400, "código 2FA obrigatório")
|
||||||
|
if not auth.verify_user_totp(user.username, code):
|
||||||
|
raise HTTPException(401, "código 2FA inválido")
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
with auth.db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE desk_users SET password_hash = ?, updated_at = ? WHERE username = ?",
|
||||||
|
(auth.hash_password(body.new_password), now, user.username),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "message": "Senha alterada com sucesso"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
def list_users(user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_manage_users(user.role):
|
||||||
|
raise HTTPException(403, "insufficient permissions")
|
||||||
|
with auth.db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.username, u.role, u.display_name, u.active, u.last_login_at,
|
||||||
|
u.created_at, u.updated_at, u.email, u.phone, u.mfa_enabled, u.totp_enabled,
|
||||||
|
(SELECT COUNT(*) FROM desk_backup_codes b
|
||||||
|
WHERE b.username = u.username AND b.used_at IS NULL) AS backup_codes_remaining
|
||||||
|
FROM desk_users u ORDER BY u.username
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
users = []
|
||||||
|
for row in rows:
|
||||||
|
item = auth.user_public_dict(row)
|
||||||
|
item["totp_enabled"] = bool(row["totp_enabled"])
|
||||||
|
item["backup_codes_remaining"] = int(row["backup_codes_remaining"] or 0)
|
||||||
|
users.append(item)
|
||||||
|
return {"users": users}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{username}")
|
||||||
|
def update_user(
|
||||||
|
username: str,
|
||||||
|
body: UserUpdateRequest,
|
||||||
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
||||||
|
):
|
||||||
|
if not can_manage_users(user.role):
|
||||||
|
raise HTTPException(403, "insufficient permissions")
|
||||||
|
target = username.strip()
|
||||||
|
if target.lower() != "root":
|
||||||
|
target = target.lower()
|
||||||
|
updates: list[str] = []
|
||||||
|
params: list[object] = []
|
||||||
|
if body.role is not None:
|
||||||
|
if body.role not in ROLES:
|
||||||
|
raise HTTPException(400, "invalid role")
|
||||||
|
updates.append("role = ?")
|
||||||
|
params.append(body.role)
|
||||||
|
if body.active is not None:
|
||||||
|
updates.append("active = ?")
|
||||||
|
params.append(1 if body.active else 0)
|
||||||
|
if body.display_name is not None:
|
||||||
|
updates.append("display_name = ?")
|
||||||
|
params.append(body.display_name)
|
||||||
|
if body.password:
|
||||||
|
updates.append("password_hash = ?")
|
||||||
|
params.append(auth.hash_password(body.password))
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(400, "no fields to update")
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
updates.append("updated_at = ?")
|
||||||
|
params.append(now)
|
||||||
|
params.append(target)
|
||||||
|
with auth.db() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
f"UPDATE desk_users SET {', '.join(updates)} WHERE username = ?",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise HTTPException(404, "user not found")
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.username, u.role, u.display_name, u.active, u.last_login_at,
|
||||||
|
u.created_at, u.updated_at, u.email, u.phone, u.mfa_enabled, u.totp_enabled,
|
||||||
|
(SELECT COUNT(*) FROM desk_backup_codes b
|
||||||
|
WHERE b.username = u.username AND b.used_at IS NULL) AS backup_codes_remaining
|
||||||
|
FROM desk_users u WHERE u.username = ?
|
||||||
|
""",
|
||||||
|
(target,),
|
||||||
|
).fetchone()
|
||||||
|
item = auth.user_public_dict(row)
|
||||||
|
item["totp_enabled"] = bool(row["totp_enabled"])
|
||||||
|
item["backup_codes_remaining"] = int(row["backup_codes_remaining"] or 0)
|
||||||
|
return {"user": item}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{username}/reset-2fa")
|
||||||
|
def reset_user_2fa(
|
||||||
|
username: str,
|
||||||
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
||||||
|
):
|
||||||
|
if not can_manage_users(user.role):
|
||||||
|
raise HTTPException(403, "insufficient permissions")
|
||||||
|
target = username.strip()
|
||||||
|
if target.lower() != "root":
|
||||||
|
target = target.lower()
|
||||||
|
if target == "root":
|
||||||
|
raise HTTPException(400, "não é possível resetar 2FA do root por aqui")
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
with auth.db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT username, email, totp_enabled FROM desk_users WHERE username = ?",
|
||||||
|
(target,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "user not found")
|
||||||
|
if not row["totp_enabled"]:
|
||||||
|
raise HTTPException(400, "utilizador não tem 2FA ativo")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE desk_users
|
||||||
|
SET totp_secret = NULL, totp_enabled = 0, mfa_enabled = 0, updated_at = ?
|
||||||
|
WHERE username = ?
|
||||||
|
""",
|
||||||
|
(now, target),
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM desk_backup_codes WHERE username = ?", (target,))
|
||||||
|
conn.commit()
|
||||||
|
email = row["email"] or target
|
||||||
|
mail_notify.notify_admin_2fa_reset(target, email, user.username)
|
||||||
|
return {"ok": True, "message": f"2FA resetado para {target}. O utilizador pode reconfigurar no login."}
|
||||||
107
ligbox-ops-platform/api/app/backup_codes.py
Normal file
107
ligbox-ops-platform/api/app/backup_codes.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
"""Single-use backup codes for Desk 2FA (Spec 004 extension)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
BACKUP_CODE_COUNT = 10
|
||||||
|
_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_column(conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None:
|
||||||
|
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||||
|
if column not in cols:
|
||||||
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}")
|
||||||
|
|
||||||
|
|
||||||
|
def init_backup_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS desk_backup_codes (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_backup_codes_user ON desk_backup_codes(username)"
|
||||||
|
)
|
||||||
|
for col, ddl in [
|
||||||
|
("recovery_email_otp", "recovery_email_otp TEXT"),
|
||||||
|
("recovery_email_otp_expires", "recovery_email_otp_expires TEXT"),
|
||||||
|
]:
|
||||||
|
_ensure_column(conn, "desk_users", col, ddl)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_code(code: str) -> str:
|
||||||
|
return code.strip().upper().replace(" ", "").replace("-", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_code(raw: str) -> str:
|
||||||
|
return f"{raw[:4]}-{raw[4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_backup_codes(count: int = BACKUP_CODE_COUNT) -> list[str]:
|
||||||
|
codes: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
while len(codes) < count:
|
||||||
|
raw = "".join(secrets.choice(_CHARS) for _ in range(8))
|
||||||
|
formatted = _format_code(raw)
|
||||||
|
if formatted not in seen:
|
||||||
|
seen.add(formatted)
|
||||||
|
codes.append(formatted)
|
||||||
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
def hash_backup_code(username: str, code: str) -> str:
|
||||||
|
norm = _normalize_code(code)
|
||||||
|
return hashlib.sha256(f"{username}:{norm}".encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def store_backup_codes(conn: sqlite3.Connection, username: str, codes: list[str]) -> None:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
conn.execute("DELETE FROM desk_backup_codes WHERE username = ?", (username,))
|
||||||
|
for code in codes:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO desk_backup_codes (username, code_hash, created_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(username, hash_backup_code(username, code), now),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def consume_backup_code(conn: sqlite3.Connection, username: str, code: str) -> bool:
|
||||||
|
h = hash_backup_code(username, code)
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM desk_backup_codes
|
||||||
|
WHERE username = ? AND code_hash = ? AND used_at IS NULL
|
||||||
|
""",
|
||||||
|
(username, h),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE desk_backup_codes SET used_at = ? WHERE id = ?",
|
||||||
|
(now, row["id"]),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def count_remaining(conn: sqlite3.Connection, username: str) -> int:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) c FROM desk_backup_codes
|
||||||
|
WHERE username = ? AND used_at IS NULL
|
||||||
|
""",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["c"]) if row else 0
|
||||||
115
ligbox-ops-platform/api/app/billing_routes.py
Normal file
115
ligbox-ops-platform/api/app/billing_routes.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""Billing API routes — Spec 023."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app import auth, billing_store
|
||||||
|
from app.permissions import can_manage_billing, can_read_billing, should_mask_sensitive
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/billing", tags=["billing"])
|
||||||
|
|
||||||
|
|
||||||
|
class PatchBillingBody(BaseModel):
|
||||||
|
billing_state: str | None = None
|
||||||
|
recurrence_active: bool | None = None
|
||||||
|
external_customer_id: str | None = None
|
||||||
|
plan_code: str | None = None
|
||||||
|
activated_by: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reader(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
|
||||||
|
if not can_read_billing(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
|
||||||
|
if not can_manage_billing(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
def billing_summary(user: auth.DeskUser = Depends(_reader)):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
data = billing_store.summary(conn)
|
||||||
|
if should_mask_sensitive(user.role):
|
||||||
|
data["recent_validations"] = [
|
||||||
|
billing_store._row_dict(r, mask=True)
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT * FROM billing_accounts ORDER BY updated_at DESC LIMIT 5"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
return data
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/accounts")
|
||||||
|
def list_billing_accounts(
|
||||||
|
billing_state: str = "",
|
||||||
|
domain: str = "",
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
user: auth.DeskUser = Depends(_reader),
|
||||||
|
):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
mask = should_mask_sensitive(user.role)
|
||||||
|
return billing_store.list_accounts(
|
||||||
|
conn,
|
||||||
|
billing_state=billing_state.strip() or None,
|
||||||
|
domain=domain.strip() or None,
|
||||||
|
limit=limit,
|
||||||
|
mask=mask,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/accounts/by-domain/{domain}")
|
||||||
|
def billing_by_domain(domain: str, user: auth.DeskUser = Depends(_reader)):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
acc = billing_store.get_by_domain(conn, domain, mask=should_mask_sensitive(user.role))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not acc:
|
||||||
|
raise HTTPException(404, "conta não encontrada")
|
||||||
|
return acc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/accounts/{account_id}")
|
||||||
|
def get_billing_account(account_id: int, user: auth.DeskUser = Depends(_reader)):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
acc = billing_store.get_account(conn, account_id, mask=should_mask_sensitive(user.role))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not acc:
|
||||||
|
raise HTTPException(404, "conta não encontrada")
|
||||||
|
return acc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/accounts/{account_id}")
|
||||||
|
def patch_billing_account(
|
||||||
|
account_id: int,
|
||||||
|
body: PatchBillingBody,
|
||||||
|
user: auth.DeskUser = Depends(_manager),
|
||||||
|
):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
fields = body.model_dump(exclude_none=True)
|
||||||
|
if body.recurrence_active and not fields.get("activated_by"):
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
fields["activated_by"] = user.username
|
||||||
|
fields["activated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
acc = billing_store.patch_account(conn, account_id, **fields)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not acc:
|
||||||
|
raise HTTPException(404, "conta não encontrada")
|
||||||
|
return acc
|
||||||
272
ligbox-ops-platform/api/app/billing_store.py
Normal file
272
ligbox-ops-platform/api/app/billing_store.py
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
"""Billing accounts store — Spec 023."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
FOSSBILLING_URL = "https://financeiro.ligbox.com.br"
|
||||||
|
ODOO_URL = "https://financeiro.ligbox.com.br/odoo/web/login?db=ligbox"
|
||||||
|
|
||||||
|
TAX_ID_RE = re.compile(r"\d")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def init_schema(conn) -> None:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS billing_accounts (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
session_id TEXT,
|
||||||
|
ticket_id INTEGER,
|
||||||
|
tax_id TEXT,
|
||||||
|
legal_name TEXT,
|
||||||
|
trade_name TEXT,
|
||||||
|
email_billing TEXT,
|
||||||
|
company_profile_json TEXT,
|
||||||
|
billing_state TEXT NOT NULL DEFAULT 'awaiting_billing_validation',
|
||||||
|
recurrence_active INTEGER NOT NULL DEFAULT 0,
|
||||||
|
external_customer_id TEXT,
|
||||||
|
external_subscription_id TEXT,
|
||||||
|
payment_provider TEXT,
|
||||||
|
plan_code TEXT,
|
||||||
|
activated_at TEXT,
|
||||||
|
activated_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_billing_domain ON billing_accounts(domain);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_tax_id(tax_id: str | None) -> str:
|
||||||
|
if not tax_id:
|
||||||
|
return "—"
|
||||||
|
digits = TAX_ID_RE.sub("", tax_id)
|
||||||
|
if len(digits) < 4:
|
||||||
|
return "***"
|
||||||
|
return f"{'*' * (len(digits) - 4)}{digits[-4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_email(email: str | None) -> str:
|
||||||
|
if not email or "@" not in email:
|
||||||
|
return "—"
|
||||||
|
local, dom = email.split("@", 1)
|
||||||
|
if len(local) <= 2:
|
||||||
|
return f"**@{dom}"
|
||||||
|
return f"{local[:2]}***@{dom}"
|
||||||
|
|
||||||
|
|
||||||
|
def _row_dict(row, *, mask: bool = False) -> dict[str, Any]:
|
||||||
|
profile = {}
|
||||||
|
if row["company_profile_json"]:
|
||||||
|
try:
|
||||||
|
profile = json.loads(row["company_profile_json"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
profile = {}
|
||||||
|
out = {
|
||||||
|
"id": row["id"],
|
||||||
|
"domain": row["domain"],
|
||||||
|
"session_id": row["session_id"],
|
||||||
|
"ticket_id": row["ticket_id"],
|
||||||
|
"tax_id": _mask_tax_id(row["tax_id"]) if mask else row["tax_id"],
|
||||||
|
"legal_name": row["legal_name"],
|
||||||
|
"trade_name": row["trade_name"],
|
||||||
|
"email_billing": _mask_email(row["email_billing"]) if mask else row["email_billing"],
|
||||||
|
"company_profile": profile if not mask else _mask_profile(profile),
|
||||||
|
"billing_state": row["billing_state"],
|
||||||
|
"recurrence_active": bool(row["recurrence_active"]),
|
||||||
|
"external_customer_id": row["external_customer_id"],
|
||||||
|
"external_subscription_id": row["external_subscription_id"],
|
||||||
|
"payment_provider": row["payment_provider"],
|
||||||
|
"plan_code": row["plan_code"],
|
||||||
|
"activated_at": row["activated_at"],
|
||||||
|
"activated_by": row["activated_by"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
"links": {
|
||||||
|
"fossbilling": FOSSBILLING_URL,
|
||||||
|
"odoo": ODOO_URL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_profile(profile: dict) -> dict:
|
||||||
|
p = dict(profile)
|
||||||
|
if p.get("tax_id"):
|
||||||
|
p["tax_id"] = _mask_tax_id(str(p["tax_id"]))
|
||||||
|
if p.get("email_billing"):
|
||||||
|
p["email_billing"] = _mask_email(str(p["email_billing"]))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_from_company_validated(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
domain: str,
|
||||||
|
session_id: str | None,
|
||||||
|
ticket_id: int | None,
|
||||||
|
data: dict | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
dom = domain.strip().lower()
|
||||||
|
profile = (data or {}).get("company_profile") or {}
|
||||||
|
billing_state = (data or {}).get("billing_state") or "awaiting_billing_validation"
|
||||||
|
now = _now()
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM billing_accounts WHERE domain = ?",
|
||||||
|
(dom,),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE billing_accounts SET
|
||||||
|
session_id = COALESCE(?, session_id),
|
||||||
|
ticket_id = COALESCE(?, ticket_id),
|
||||||
|
tax_id = ?, legal_name = ?, trade_name = ?, email_billing = ?,
|
||||||
|
company_profile_json = ?, billing_state = ?, updated_at = ?
|
||||||
|
WHERE domain = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
session_id,
|
||||||
|
ticket_id,
|
||||||
|
profile.get("tax_id"),
|
||||||
|
profile.get("legal_name"),
|
||||||
|
profile.get("trade_name"),
|
||||||
|
profile.get("email_billing"),
|
||||||
|
json.dumps(profile, ensure_ascii=False),
|
||||||
|
billing_state,
|
||||||
|
now,
|
||||||
|
dom,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
acc_id = int(existing["id"])
|
||||||
|
else:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO billing_accounts
|
||||||
|
(domain, session_id, ticket_id, tax_id, legal_name, trade_name, email_billing,
|
||||||
|
company_profile_json, billing_state, recurrence_active, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
dom,
|
||||||
|
session_id,
|
||||||
|
ticket_id,
|
||||||
|
profile.get("tax_id"),
|
||||||
|
profile.get("legal_name"),
|
||||||
|
profile.get("trade_name"),
|
||||||
|
profile.get("email_billing"),
|
||||||
|
json.dumps(profile, ensure_ascii=False),
|
||||||
|
billing_state,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
acc_id = int(cur.lastrowid)
|
||||||
|
conn.commit()
|
||||||
|
return get_account(conn, acc_id) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_account(conn, account_id: int, *, mask: bool = False) -> dict[str, Any] | None:
|
||||||
|
row = conn.execute("SELECT * FROM billing_accounts WHERE id = ?", (account_id,)).fetchone()
|
||||||
|
return _row_dict(row, mask=mask) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_domain(conn, domain: str, *, mask: bool = False) -> dict[str, Any] | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM billing_accounts WHERE domain = ?",
|
||||||
|
(domain.strip().lower(),),
|
||||||
|
).fetchone()
|
||||||
|
return _row_dict(row, mask=mask) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_accounts(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
billing_state: str | None = None,
|
||||||
|
domain: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
mask: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
limit = max(1, min(limit, 500))
|
||||||
|
clauses = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if billing_state:
|
||||||
|
clauses.append("billing_state = ?")
|
||||||
|
params.append(billing_state)
|
||||||
|
if domain:
|
||||||
|
clauses.append("domain LIKE ?")
|
||||||
|
params.append(f"%{domain.strip().lower()}%")
|
||||||
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM billing_accounts {where} ORDER BY updated_at DESC LIMIT ?",
|
||||||
|
(*params, limit),
|
||||||
|
).fetchall()
|
||||||
|
total = conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM billing_accounts {where}",
|
||||||
|
tuple(params),
|
||||||
|
).fetchone()[0]
|
||||||
|
return {
|
||||||
|
"accounts": [_row_dict(r, mask=mask) for r in rows],
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def patch_account(conn, account_id: int, **fields) -> dict[str, Any] | None:
|
||||||
|
allowed = {
|
||||||
|
"billing_state",
|
||||||
|
"recurrence_active",
|
||||||
|
"external_customer_id",
|
||||||
|
"external_subscription_id",
|
||||||
|
"payment_provider",
|
||||||
|
"plan_code",
|
||||||
|
"activated_at",
|
||||||
|
"activated_by",
|
||||||
|
"ticket_id",
|
||||||
|
}
|
||||||
|
if fields.get("recurrence_active"):
|
||||||
|
fields.setdefault("billing_state", "billing_active")
|
||||||
|
sets = []
|
||||||
|
params: list[Any] = []
|
||||||
|
for key, val in fields.items():
|
||||||
|
if key not in allowed:
|
||||||
|
continue
|
||||||
|
if key == "recurrence_active":
|
||||||
|
val = 1 if val else 0
|
||||||
|
sets.append(f"{key} = ?")
|
||||||
|
params.append(val)
|
||||||
|
if not sets:
|
||||||
|
return get_account(conn, account_id)
|
||||||
|
sets.append("updated_at = ?")
|
||||||
|
params.append(_now())
|
||||||
|
params.append(account_id)
|
||||||
|
conn.execute(f"UPDATE billing_accounts SET {', '.join(sets)} WHERE id = ?", params)
|
||||||
|
conn.commit()
|
||||||
|
return get_account(conn, account_id)
|
||||||
|
|
||||||
|
|
||||||
|
def summary(conn) -> dict[str, Any]:
|
||||||
|
pending = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM billing_accounts WHERE billing_state = 'awaiting_billing_validation'"
|
||||||
|
).fetchone()[0]
|
||||||
|
active = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM billing_accounts WHERE recurrence_active = 1"
|
||||||
|
).fetchone()[0]
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM billing_accounts").fetchone()[0]
|
||||||
|
recent = conn.execute(
|
||||||
|
"SELECT * FROM billing_accounts ORDER BY updated_at DESC LIMIT 5"
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"billing_pending": pending,
|
||||||
|
"billing_active": active,
|
||||||
|
"billing_total": total,
|
||||||
|
"recent_validations": [_row_dict(r) for r in recent],
|
||||||
|
}
|
||||||
120
ligbox-ops-platform/api/app/carbonio_release_routes.py
Normal file
120
ligbox-ops-platform/api/app/carbonio_release_routes.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
"""Rotas libertação ACCOUNT_EXISTS — Spec 022."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app import auth, carbonio_release_store, vm112_domains
|
||||||
|
from app.permissions import can_read_tickets
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/carbonio-blocks", tags=["carbonio-release"])
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveBlockBody(BaseModel):
|
||||||
|
confirm_email: str = Field(..., min_length=5)
|
||||||
|
password: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_ticket_reader(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
|
||||||
|
if not can_read_tickets(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_user_password(conn, username: str, password: str) -> bool:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT password_hash FROM desk_users WHERE username = ? AND active = 1",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
if not row or not row["password_hash"]:
|
||||||
|
return False
|
||||||
|
return auth.verify_password(password, row["password_hash"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_carbonio_blocks(
|
||||||
|
status: str = Query("pending"),
|
||||||
|
session_id: str = "",
|
||||||
|
ticket_id: int | None = None,
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
user: auth.DeskUser = Depends(_require_ticket_reader),
|
||||||
|
):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
return carbonio_release_store.list_blocks(
|
||||||
|
conn,
|
||||||
|
status=status or "all",
|
||||||
|
session_id=session_id.strip() or None,
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{block_id}")
|
||||||
|
def get_carbonio_block(
|
||||||
|
block_id: int,
|
||||||
|
user: auth.DeskUser = Depends(_require_ticket_reader),
|
||||||
|
):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
block = carbonio_release_store.get_block(conn, block_id)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not block:
|
||||||
|
raise HTTPException(404, "bloqueio não encontrado")
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{block_id}/resolve")
|
||||||
|
def resolve_carbonio_block(
|
||||||
|
block_id: int,
|
||||||
|
body: ResolveBlockBody,
|
||||||
|
user: auth.DeskUser = Depends(_require_ticket_reader),
|
||||||
|
):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
block = carbonio_release_store.get_block(conn, block_id)
|
||||||
|
if not block:
|
||||||
|
raise HTTPException(404, "bloqueio não encontrado")
|
||||||
|
if block["status"] == "resolved":
|
||||||
|
raise HTTPException(409, f"Já resolvido por {block.get('resolved_by') or 'outro técnico'}")
|
||||||
|
if body.confirm_email.lower().strip() != block["email"].lower():
|
||||||
|
raise HTTPException(400, "E-mail de confirmação não coincide")
|
||||||
|
if not _verify_user_password(conn, user.username, body.password):
|
||||||
|
raise HTTPException(403, "Senha incorrecta")
|
||||||
|
|
||||||
|
try:
|
||||||
|
vm_result = vm112_domains.delete_carbonio_account(block["email"])
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"VM112/Carbonio: {e}") from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = carbonio_release_store.resolve_block(
|
||||||
|
conn,
|
||||||
|
block_id,
|
||||||
|
resolved_by=user.username,
|
||||||
|
note=vm_result.get("message", "zmprov da"),
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
if str(e) == "already_resolved":
|
||||||
|
raise HTTPException(409, "Outro técnico já resolveu este bloqueio") from e
|
||||||
|
raise HTTPException(404, "Bloqueio indisponível") from e
|
||||||
|
|
||||||
|
if block.get("ticket_id"):
|
||||||
|
carbonio_release_store.append_ticket_resolution_note(
|
||||||
|
conn,
|
||||||
|
int(block["ticket_id"]),
|
||||||
|
email=block["email"],
|
||||||
|
by=user.username,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"block": resolved,
|
||||||
|
"vm112": vm_result,
|
||||||
|
"message": f"Conta {block['email']} removida do Carbonio. Peça ao cliente para repetir «Criar conta» no wizard.",
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
239
ligbox-ops-platform/api/app/carbonio_release_store.py
Normal file
239
ligbox-ops-platform/api/app/carbonio_release_store.py
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
"""Bloqueios ACCOUNT_EXISTS — libertar e-mail Carbonio (Spec 022)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def init_schema(conn) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS carbonio_account_blocks (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
session_id TEXT,
|
||||||
|
ticket_id INTEGER,
|
||||||
|
webhook_event_id INTEGER,
|
||||||
|
error_message TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
resolved_by TEXT,
|
||||||
|
resolved_at TEXT,
|
||||||
|
resolution_note TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_carbonio_blocks_status
|
||||||
|
ON carbonio_account_blocks(status, created_at DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_carbonio_blocks_session
|
||||||
|
ON carbonio_account_blocks(session_id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_account_exists_failure(event: str, data: dict | None) -> bool:
|
||||||
|
if event != "onboarding.failed":
|
||||||
|
return False
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return False
|
||||||
|
err = str(data.get("error") or data.get("message") or data.get("reason") or "")
|
||||||
|
low = err.lower()
|
||||||
|
return (
|
||||||
|
"account_exists" in low
|
||||||
|
or "account.exists" in low
|
||||||
|
or "já existe" in low
|
||||||
|
or "already exists" in low
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_email(domain: str | None, data: dict | None) -> str | None:
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key in ("email", "account", "mailbox"):
|
||||||
|
val = (data.get(key) or "").strip().lower()
|
||||||
|
if EMAIL_RE.match(val):
|
||||||
|
return val
|
||||||
|
err = str(data.get("error") or "")
|
||||||
|
m = re.search(r"[\w.+-]+@[\w.-]+\.\w+", err)
|
||||||
|
if m:
|
||||||
|
return m.group(0).lower()
|
||||||
|
if domain and isinstance(data, dict):
|
||||||
|
local = (data.get("local_part") or "").strip().lower()
|
||||||
|
if local:
|
||||||
|
return f"{local}@{domain.lower().strip()}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_from_webhook(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
event: str,
|
||||||
|
domain: str | None,
|
||||||
|
session_id: str | None,
|
||||||
|
data: dict | None,
|
||||||
|
webhook_event_id: int | None,
|
||||||
|
ticket_id: int | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if not is_account_exists_failure(event, data):
|
||||||
|
return None
|
||||||
|
email = _extract_email(domain, data)
|
||||||
|
if not email:
|
||||||
|
return None
|
||||||
|
dom = (domain or email.split("@", 1)[-1]).lower().strip()
|
||||||
|
sid = (session_id or "").strip() or None
|
||||||
|
err_msg = str((data or {}).get("error") or "")[:2000]
|
||||||
|
now = _now()
|
||||||
|
|
||||||
|
if sid:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, status FROM carbonio_account_blocks
|
||||||
|
WHERE email = ? AND session_id = ? AND status = 'pending'
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(email, sid),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE carbonio_account_blocks
|
||||||
|
SET error_message = ?, webhook_event_id = COALESCE(?, webhook_event_id),
|
||||||
|
ticket_id = COALESCE(?, ticket_id)
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(err_msg, webhook_event_id, ticket_id, row["id"]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return get_block(conn, int(row["id"]))
|
||||||
|
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO carbonio_account_blocks
|
||||||
|
(email, domain, session_id, ticket_id, webhook_event_id, error_message, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)
|
||||||
|
""",
|
||||||
|
(email, dom, sid, ticket_id, webhook_event_id, err_msg, now),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return get_block(conn, int(cur.lastrowid))
|
||||||
|
|
||||||
|
|
||||||
|
def get_block(conn, block_id: int) -> dict[str, Any] | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM carbonio_account_blocks WHERE id = ?",
|
||||||
|
(block_id,),
|
||||||
|
).fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"email": row["email"],
|
||||||
|
"domain": row["domain"],
|
||||||
|
"session_id": row["session_id"],
|
||||||
|
"ticket_id": row["ticket_id"],
|
||||||
|
"webhook_event_id": row["webhook_event_id"],
|
||||||
|
"error_message": row["error_message"],
|
||||||
|
"status": row["status"],
|
||||||
|
"resolved_by": row["resolved_by"],
|
||||||
|
"resolved_at": row["resolved_at"],
|
||||||
|
"resolution_note": row["resolution_note"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_blocks(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
status: str | None = "pending",
|
||||||
|
session_id: str | None = None,
|
||||||
|
ticket_id: int | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
limit = max(1, min(limit, 500))
|
||||||
|
clauses = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if status and status != "all":
|
||||||
|
clauses.append("status = ?")
|
||||||
|
params.append(status)
|
||||||
|
if session_id:
|
||||||
|
clauses.append("session_id = ?")
|
||||||
|
params.append(session_id.strip())
|
||||||
|
if ticket_id:
|
||||||
|
clauses.append("ticket_id = ?")
|
||||||
|
params.append(ticket_id)
|
||||||
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM carbonio_account_blocks {where}
|
||||||
|
ORDER BY id DESC LIMIT ?
|
||||||
|
""",
|
||||||
|
(*params, limit),
|
||||||
|
).fetchall()
|
||||||
|
total = conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM carbonio_account_blocks {where}",
|
||||||
|
tuple(params),
|
||||||
|
).fetchone()[0]
|
||||||
|
return {
|
||||||
|
"blocks": [_row_to_dict(r) for r in rows],
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_block(
|
||||||
|
conn,
|
||||||
|
block_id: int,
|
||||||
|
*,
|
||||||
|
resolved_by: str,
|
||||||
|
note: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
now = _now()
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE carbonio_account_blocks
|
||||||
|
SET status = 'resolved', resolved_by = ?, resolved_at = ?, resolution_note = ?
|
||||||
|
WHERE id = ? AND status = 'pending'
|
||||||
|
""",
|
||||||
|
(resolved_by, now, note[:500], block_id),
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
existing = get_block(conn, block_id)
|
||||||
|
if existing and existing["status"] == "resolved":
|
||||||
|
raise ValueError("already_resolved")
|
||||||
|
raise ValueError("not_found_or_locked")
|
||||||
|
conn.commit()
|
||||||
|
return get_block(conn, block_id) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def append_ticket_resolution_note(conn, ticket_id: int, *, email: str, by: str) -> None:
|
||||||
|
row = conn.execute("SELECT payload FROM tickets WHERE id = ?", (ticket_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payload = json.loads(row["payload"] or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
payload = {}
|
||||||
|
notes = payload.get("carbonio_release_notes") or []
|
||||||
|
notes.append({"at": _now(), "email": email, "by": by, "action": "account_deleted"})
|
||||||
|
payload["carbonio_release_notes"] = notes[-20:]
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tickets SET payload = ? WHERE id = ?",
|
||||||
|
(json.dumps(payload, ensure_ascii=False), ticket_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
181
ligbox-ops-platform/api/app/cloudflare_dns.py
Normal file
181
ligbox-ops-platform/api/app/cloudflare_dns.py
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
"""Cloudflare DNS records for domain management (read-only)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
CF_API = "https://api.cloudflare.com/client/v4"
|
||||||
|
|
||||||
|
EMAIL_PURPOSES = frozenset({"mx", "spf", "dkim", "dmarc", "mail-host", "autodiscover", "mail-alias"})
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens() -> list[str]:
|
||||||
|
raw = os.getenv("CLOUDFLARE_API_TOKENS") or os.getenv("CLOUDFLARE_API_TOKEN") or ""
|
||||||
|
return [t.strip() for t in raw.replace(";", ",").split(",") if t.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_candidates(domain: str) -> list[str]:
|
||||||
|
domain = domain.lower().strip().rstrip(".")
|
||||||
|
parts = domain.split(".")
|
||||||
|
if len(parts) < 2:
|
||||||
|
return [domain] if domain else []
|
||||||
|
return [".".join(parts[i:]) for i in range(len(parts) - 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(token: str) -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_record(name: str, rtype: str, content: str) -> str:
|
||||||
|
n = (name or "").lower().rstrip(".")
|
||||||
|
c = (content or "").lower()
|
||||||
|
if rtype == "MX":
|
||||||
|
return "mx"
|
||||||
|
if rtype == "TXT":
|
||||||
|
if "v=spf1" in c:
|
||||||
|
return "spf"
|
||||||
|
if "_domainkey" in n or "v=dkim1" in c:
|
||||||
|
return "dkim"
|
||||||
|
if "_dmarc" in n or "v=dmarc1" in c:
|
||||||
|
return "dmarc"
|
||||||
|
if n.startswith("mail.") and rtype in ("A", "AAAA", "CNAME"):
|
||||||
|
return "mail-host"
|
||||||
|
if "autodiscover" in n or "autoconfig" in n:
|
||||||
|
return "autodiscover"
|
||||||
|
if rtype == "CNAME" and ("mail" in n or "autodiscover" in n):
|
||||||
|
return "mail-alias"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def _record_belongs(name: str, domain: str) -> bool:
|
||||||
|
rn = (name or "").lower().rstrip(".")
|
||||||
|
d = domain.lower().strip().rstrip(".")
|
||||||
|
return rn == d or rn.endswith(f".{d}")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_record(raw: dict, domain: str) -> dict[str, Any]:
|
||||||
|
name = raw.get("name", "")
|
||||||
|
rtype = raw.get("type", "")
|
||||||
|
content = raw.get("content", "")
|
||||||
|
purpose = _classify_record(name, rtype, content)
|
||||||
|
return {
|
||||||
|
"id": raw.get("id"),
|
||||||
|
"type": rtype,
|
||||||
|
"name": name.rstrip("."),
|
||||||
|
"content": content,
|
||||||
|
"priority": raw.get("priority"),
|
||||||
|
"proxied": raw.get("proxied"),
|
||||||
|
"ttl": raw.get("ttl"),
|
||||||
|
"purpose": purpose,
|
||||||
|
"email_related": purpose in EMAIL_PURPOSES,
|
||||||
|
"modified_on": raw.get("modified_on"),
|
||||||
|
"created_on": raw.get("created_on"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_zone(client: httpx.AsyncClient, token: str, domain: str) -> dict | None:
|
||||||
|
for candidate in _parent_candidates(domain):
|
||||||
|
res = await client.get(
|
||||||
|
f"{CF_API}/zones",
|
||||||
|
headers=_headers(token),
|
||||||
|
params={"name": candidate, "status": "active"},
|
||||||
|
)
|
||||||
|
if res.status_code != 200:
|
||||||
|
continue
|
||||||
|
data = res.json()
|
||||||
|
if not data.get("success"):
|
||||||
|
continue
|
||||||
|
zones = data.get("result") or []
|
||||||
|
if zones:
|
||||||
|
z = zones[0]
|
||||||
|
return {"id": z.get("id"), "name": z.get("name"), "status": z.get("status")}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_zone_records(client: httpx.AsyncClient, token: str, zone_id: str) -> list[dict]:
|
||||||
|
records: list[dict] = []
|
||||||
|
page = 1
|
||||||
|
while page <= 10:
|
||||||
|
res = await client.get(
|
||||||
|
f"{CF_API}/zones/{zone_id}/dns_records",
|
||||||
|
headers=_headers(token),
|
||||||
|
params={"per_page": 100, "page": page},
|
||||||
|
)
|
||||||
|
if res.status_code != 200:
|
||||||
|
break
|
||||||
|
data = res.json()
|
||||||
|
if not data.get("success"):
|
||||||
|
break
|
||||||
|
batch = data.get("result") or []
|
||||||
|
records.extend(batch)
|
||||||
|
info = data.get("result_info") or {}
|
||||||
|
if page >= (info.get("total_pages") or 1):
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_domain_dns(domain: str, *, email_service: bool | None = None) -> dict[str, Any]:
|
||||||
|
domain = domain.lower().strip().rstrip(".")
|
||||||
|
tokens = _tokens()
|
||||||
|
if not tokens:
|
||||||
|
return {
|
||||||
|
"domain": domain,
|
||||||
|
"zone": None,
|
||||||
|
"email_service": bool(email_service),
|
||||||
|
"service_type": "email_server" if email_service else None,
|
||||||
|
"records": [],
|
||||||
|
"email_records": [],
|
||||||
|
"summary": {"total": 0, "email_related": 0},
|
||||||
|
"error": "CLOUDFLARE_API_TOKEN não configurado no servidor",
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||||
|
zone = None
|
||||||
|
token_used = None
|
||||||
|
for token in tokens:
|
||||||
|
zone = await _find_zone(client, token, domain)
|
||||||
|
if zone:
|
||||||
|
token_used = token
|
||||||
|
break
|
||||||
|
|
||||||
|
if not zone or not token_used:
|
||||||
|
return {
|
||||||
|
"domain": domain,
|
||||||
|
"zone": None,
|
||||||
|
"email_service": bool(email_service),
|
||||||
|
"service_type": "email_server" if email_service else None,
|
||||||
|
"records": [],
|
||||||
|
"email_records": [],
|
||||||
|
"summary": {"total": 0, "email_related": 0},
|
||||||
|
"error": f"Zona Cloudflare não encontrada para {domain}",
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_records = await _list_zone_records(client, token_used, zone["id"])
|
||||||
|
scoped = [_normalize_record(r, domain) for r in raw_records if _record_belongs(r.get("name", ""), domain)]
|
||||||
|
scoped.sort(key=lambda r: (0 if r["email_related"] else 1, r["type"], r["name"]))
|
||||||
|
|
||||||
|
email_records = [r for r in scoped if r["email_related"]]
|
||||||
|
is_email = email_service if email_service is not None else len(email_records) > 0
|
||||||
|
|
||||||
|
purposes: dict[str, int] = {}
|
||||||
|
for r in scoped:
|
||||||
|
purposes[r["purpose"]] = purposes.get(r["purpose"], 0) + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"domain": domain,
|
||||||
|
"zone": zone,
|
||||||
|
"email_service": is_email,
|
||||||
|
"service_type": "email_server" if is_email else "other",
|
||||||
|
"records": scoped,
|
||||||
|
"email_records": email_records,
|
||||||
|
"summary": {
|
||||||
|
"total": len(scoped),
|
||||||
|
"email_related": len(email_records),
|
||||||
|
"by_purpose": purposes,
|
||||||
|
},
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
3
ligbox-ops-platform/api/app/collectors/__init__.py
Normal file
3
ligbox-ops-platform/api/app/collectors/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .base import run_audit
|
||||||
|
|
||||||
|
__all__ = ["run_audit"]
|
||||||
55
ligbox-ops-platform/api/app/collectors/base.py
Normal file
55
ligbox-ops-platform/api/app/collectors/base.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""Run all read-only audit checks for a tenant domain."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import dns, vm112, webmail
|
||||||
|
|
||||||
|
CHECK_LABELS = {
|
||||||
|
"carbonio": "Carbonio domain",
|
||||||
|
"nginx_vhost": "carbonio-nginx vhost",
|
||||||
|
"cert_le": "Let's Encrypt certificate",
|
||||||
|
"dns_mx": "MX record",
|
||||||
|
"dns_spf": "SPF",
|
||||||
|
"dns_dkim": "DKIM",
|
||||||
|
"dns_dmarc": "DMARC",
|
||||||
|
"webmail_http": "Webmail HTTPS",
|
||||||
|
}
|
||||||
|
|
||||||
|
TENANT_API_BASE = {
|
||||||
|
1: None, # filled from env in run_audit
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_audit(
|
||||||
|
tenant_id: int,
|
||||||
|
domain: str,
|
||||||
|
*,
|
||||||
|
vm112_api: str | None = None,
|
||||||
|
mail_public_ip: str | None = None,
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
results: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
if tenant_id == 1:
|
||||||
|
api_base = vm112_api or "http://10.10.10.112:8090"
|
||||||
|
results.update(vm112.collect(domain, api_base))
|
||||||
|
|
||||||
|
results.update(dns.collect(domain, mail_public_ip=mail_public_ip))
|
||||||
|
results.update(webmail.collect(domain))
|
||||||
|
|
||||||
|
for check_id, label in CHECK_LABELS.items():
|
||||||
|
results.setdefault(
|
||||||
|
check_id,
|
||||||
|
{
|
||||||
|
"check_id": check_id,
|
||||||
|
"label": label,
|
||||||
|
"status": "skip",
|
||||||
|
"message": "Check not run",
|
||||||
|
"evidence": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
results[check_id]["label"] = label
|
||||||
|
|
||||||
|
return results
|
||||||
86
ligbox-ops-platform/api/app/collectors/dns.py
Normal file
86
ligbox-ops-platform/api/app/collectors/dns.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
"""Public DNS checks via dig (read-only)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _dig(*args: str) -> list[str]:
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["dig", "+short", *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=8,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return []
|
||||||
|
lines = [ln.strip().strip('"') for ln in proc.stdout.splitlines() if ln.strip()]
|
||||||
|
return lines
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _result(check_id: str, label: str, status: str, message: str, evidence: dict | None = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"check_id": check_id,
|
||||||
|
"label": label,
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"evidence": evidence or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect(domain: str, mail_public_ip: str | None = None) -> dict[str, dict[str, Any]]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
mail_host = f"mail.{domain}"
|
||||||
|
results: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
mx = _dig(domain, "MX")
|
||||||
|
mx_ok = any(mail_host in line or domain in line for line in mx)
|
||||||
|
results["dns_mx"] = _result(
|
||||||
|
"dns_mx",
|
||||||
|
"MX record",
|
||||||
|
"pass" if mx_ok else "fail",
|
||||||
|
f"MX: {', '.join(mx[:3]) or 'none'}",
|
||||||
|
{"records": mx},
|
||||||
|
)
|
||||||
|
|
||||||
|
txt_root = _dig(domain, "TXT")
|
||||||
|
spf = [t for t in txt_root if t.lower().startswith("v=spf1")]
|
||||||
|
results["dns_spf"] = _result(
|
||||||
|
"dns_spf",
|
||||||
|
"SPF",
|
||||||
|
"pass" if spf else "fail",
|
||||||
|
spf[0][:120] if spf else "SPF TXT not found",
|
||||||
|
{"records": spf},
|
||||||
|
)
|
||||||
|
|
||||||
|
dkim_name = f"default._domainkey.{domain}"
|
||||||
|
dkim = _dig(dkim_name, "TXT")
|
||||||
|
results["dns_dkim"] = _result(
|
||||||
|
"dns_dkim",
|
||||||
|
"DKIM",
|
||||||
|
"pass" if dkim else "fail",
|
||||||
|
"DKIM TXT present" if dkim else f"{dkim_name} not found",
|
||||||
|
{"records": dkim[:2]},
|
||||||
|
)
|
||||||
|
|
||||||
|
dmarc_name = f"_dmarc.{domain}"
|
||||||
|
dmarc = _dig(dmarc_name, "TXT")
|
||||||
|
results["dns_dmarc"] = _result(
|
||||||
|
"dns_dmarc",
|
||||||
|
"DMARC",
|
||||||
|
"pass" if dmarc else "warn",
|
||||||
|
dmarc[0][:120] if dmarc else "DMARC TXT not found",
|
||||||
|
{"records": dmarc},
|
||||||
|
)
|
||||||
|
|
||||||
|
if mail_public_ip:
|
||||||
|
a_mail = _dig(mail_host, "A")
|
||||||
|
if mail_public_ip not in a_mail and results["dns_mx"]["status"] == "pass":
|
||||||
|
results["dns_mx"]["status"] = "warn"
|
||||||
|
results["dns_mx"]["message"] += f" (A {mail_host}: {a_mail or 'none'})"
|
||||||
|
|
||||||
|
return results
|
||||||
67
ligbox-ops-platform/api/app/collectors/vm112.py
Normal file
67
ligbox-ops-platform/api/app/collectors/vm112.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""VM112 portal infrastructure checks (read-only API)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def _result(check_id: str, label: str, ok: bool | None, message: str, evidence: dict | None = None) -> dict[str, Any]:
|
||||||
|
if ok is True:
|
||||||
|
status = "pass"
|
||||||
|
elif ok is False:
|
||||||
|
status = "fail"
|
||||||
|
else:
|
||||||
|
status = "error"
|
||||||
|
return {
|
||||||
|
"check_id": check_id,
|
||||||
|
"label": label,
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"evidence": evidence or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect(domain: str, api_base: str) -> dict[str, dict[str, Any]]:
|
||||||
|
url = f"{api_base.rstrip('/')}/api/onboarding/infrastructure/status/{domain}"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=15.0) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
if response.status_code != 200:
|
||||||
|
err = _result("carbonio", "Carbonio domain", None, f"Portal API HTTP {response.status_code}")
|
||||||
|
return {
|
||||||
|
"carbonio": err,
|
||||||
|
"nginx_vhost": {**err, "check_id": "nginx_vhost", "label": "carbonio-nginx vhost"},
|
||||||
|
"cert_le": {**err, "check_id": "cert_le", "label": "Let's Encrypt certificate"},
|
||||||
|
}
|
||||||
|
data = response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
err = _result("carbonio", "Carbonio domain", None, str(exc))
|
||||||
|
return {
|
||||||
|
"carbonio": err,
|
||||||
|
"nginx_vhost": {**err, "check_id": "nginx_vhost", "label": "carbonio-nginx vhost"},
|
||||||
|
"cert_le": {**err, "check_id": "cert_le", "label": "Let's Encrypt certificate"},
|
||||||
|
}
|
||||||
|
|
||||||
|
steps = {s.get("id"): s for s in data.get("steps") or [] if isinstance(s, dict)}
|
||||||
|
|
||||||
|
def from_step(check_id: str, label: str, step_id: str) -> dict[str, Any]:
|
||||||
|
step = steps.get(step_id) or {}
|
||||||
|
return _result(
|
||||||
|
check_id,
|
||||||
|
label,
|
||||||
|
step.get("ok"),
|
||||||
|
step.get("message") or f"Step {step_id}",
|
||||||
|
{"step_id": step_id, "ready": data.get("ready")},
|
||||||
|
)
|
||||||
|
|
||||||
|
cert = from_step("cert_le", "Let's Encrypt certificate", "cert_san")
|
||||||
|
if cert["status"] == "pass":
|
||||||
|
cert["status"] = "pass"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"carbonio": from_step("carbonio", "Carbonio domain", "carbonio_domain"),
|
||||||
|
"nginx_vhost": from_step("nginx_vhost", "carbonio-nginx vhost", "carbonio_nginx_vhost"),
|
||||||
|
"cert_le": cert,
|
||||||
|
}
|
||||||
41
ligbox-ops-platform/api/app/collectors/webmail.py
Normal file
41
ligbox-ops-platform/api/app/collectors/webmail.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Webmail HTTPS check (read-only)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def collect(domain: str) -> dict[str, dict[str, Any]]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
url = f"https://mail.{domain}/"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=12.0, follow_redirects=True, verify=True) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
code = response.status_code
|
||||||
|
if 200 <= code < 400:
|
||||||
|
status, message = "pass", f"HTTP {code}"
|
||||||
|
elif code == 403:
|
||||||
|
status, message = "warn", f"HTTP {code}"
|
||||||
|
else:
|
||||||
|
status, message = "fail", f"HTTP {code}"
|
||||||
|
return {
|
||||||
|
"webmail_http": {
|
||||||
|
"check_id": "webmail_http",
|
||||||
|
"label": "Webmail HTTPS",
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"evidence": {"url": url, "status_code": code},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"webmail_http": {
|
||||||
|
"check_id": "webmail_http",
|
||||||
|
"label": "Webmail HTTPS",
|
||||||
|
"status": "fail",
|
||||||
|
"message": str(exc)[:200],
|
||||||
|
"evidence": {"url": url},
|
||||||
|
}
|
||||||
|
}
|
||||||
199
ligbox-ops-platform/api/app/crm_leads.py
Normal file
199
ligbox-ops-platform/api/app/crm_leads.py
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
"""Abandoned onboarding → Lead CRM — Spec 012 Phase B."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ONBOARD_STALE_HOURS = int(os.getenv("ONBOARD_STALE_HOURS", "24"))
|
||||||
|
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,
|
||||||
|
"onboarding.failed": 99,
|
||||||
|
}
|
||||||
|
|
||||||
|
FUNNEL_STAGE_BY_RANK = {
|
||||||
|
1: "started",
|
||||||
|
2: "domain_validated",
|
||||||
|
3: "dns_applied",
|
||||||
|
4: "account_created",
|
||||||
|
5: "infra_synced",
|
||||||
|
6: "completed",
|
||||||
|
99: "failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
LEAD_PROMOTE_STATUSES = frozenset({"open", "escalated"})
|
||||||
|
SKIP_STAGES = frozenset({"completed", "failed"})
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_payload(raw: str | None) -> dict:
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def session_funnel_state(conn: sqlite3.Connection, session_id: str) -> dict[str, Any]:
|
||||||
|
sid = (session_id or "").strip()
|
||||||
|
if not sid:
|
||||||
|
return {"stage": "unknown", "last_event_at": None, "failed": False}
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT event_type, payload, created_at
|
||||||
|
FROM webhook_events
|
||||||
|
WHERE source = ?
|
||||||
|
ORDER BY id ASC
|
||||||
|
""",
|
||||||
|
(ONBOARD_SOURCE,),
|
||||||
|
).fetchall()
|
||||||
|
max_rank = 0
|
||||||
|
last_event_at = None
|
||||||
|
domain = None
|
||||||
|
failed = False
|
||||||
|
for row in rows:
|
||||||
|
payload = _parse_payload(row["payload"])
|
||||||
|
if (payload.get("session_id") or "").strip() != sid:
|
||||||
|
continue
|
||||||
|
if payload.get("domain"):
|
||||||
|
domain = payload.get("domain")
|
||||||
|
last_event_at = row["created_at"]
|
||||||
|
if row["event_type"] == "onboarding.failed":
|
||||||
|
failed = True
|
||||||
|
max_rank = max(max_rank, 99)
|
||||||
|
else:
|
||||||
|
rank = FUNNEL_EVENT_RANK.get(row["event_type"], 0)
|
||||||
|
if rank > max_rank and not failed:
|
||||||
|
max_rank = rank
|
||||||
|
if failed:
|
||||||
|
stage = "failed"
|
||||||
|
else:
|
||||||
|
stage = FUNNEL_STAGE_BY_RANK.get(max_rank, "started")
|
||||||
|
return {
|
||||||
|
"stage": stage,
|
||||||
|
"last_event_at": last_event_at,
|
||||||
|
"failed": failed,
|
||||||
|
"domain": domain,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_session_stale(last_event_at: str | None, stage: str, stale_hours: int) -> bool:
|
||||||
|
if not last_event_at or stage in SKIP_STAGES:
|
||||||
|
return False
|
||||||
|
cutoff = (datetime.now(timezone.utc) - timedelta(hours=stale_hours)).isoformat()
|
||||||
|
return last_event_at < cutoff
|
||||||
|
|
||||||
|
|
||||||
|
def promote_stale_leads(conn: sqlite3.Connection, stale_hours: int | None = None) -> dict[str, Any]:
|
||||||
|
hours = stale_hours if stale_hours is not None else ONBOARD_STALE_HOURS
|
||||||
|
now = _now()
|
||||||
|
promoted_ids: list[int] = []
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, status, session_id, subject, payload, created_at
|
||||||
|
FROM tickets
|
||||||
|
WHERE session_id IS NOT NULL AND session_id != ''
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 500
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
payload = _parse_payload(row["payload"])
|
||||||
|
crm_track = payload.get("crm_track") or "onboarding"
|
||||||
|
if crm_track != "onboarding":
|
||||||
|
continue
|
||||||
|
if row["status"] not in LEAD_PROMOTE_STATUSES:
|
||||||
|
continue
|
||||||
|
sid = (row["session_id"] or payload.get("session_id") or "").strip()
|
||||||
|
if not sid:
|
||||||
|
continue
|
||||||
|
meta = session_funnel_state(conn, sid)
|
||||||
|
if not is_session_stale(meta.get("last_event_at"), meta.get("stage", ""), hours):
|
||||||
|
continue
|
||||||
|
payload["crm_track"] = "lead"
|
||||||
|
payload["lead_detected_at"] = now
|
||||||
|
payload["lead_reason"] = "stale_session"
|
||||||
|
payload["lead_funnel_stage"] = meta.get("stage")
|
||||||
|
payload["lead_last_event_at"] = meta.get("last_event_at")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tickets SET payload = ? WHERE id = ?",
|
||||||
|
(json.dumps(payload), int(row["id"])),
|
||||||
|
)
|
||||||
|
promoted_ids.append(int(row["id"]))
|
||||||
|
if promoted_ids:
|
||||||
|
conn.commit()
|
||||||
|
return {
|
||||||
|
"promoted": len(promoted_ids),
|
||||||
|
"ticket_ids": promoted_ids,
|
||||||
|
"stale_hours": hours,
|
||||||
|
"ran_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lead_from_ticket(row: sqlite3.Row, conn: sqlite3.Connection | None = None) -> dict[str, Any]:
|
||||||
|
payload = _parse_payload(row["payload"])
|
||||||
|
sid = (row["session_id"] or payload.get("session_id") or "").strip()
|
||||||
|
stage = payload.get("lead_funnel_stage")
|
||||||
|
last_event = payload.get("lead_last_event_at")
|
||||||
|
domain = payload.get("domain")
|
||||||
|
if conn and sid and (not stage or not last_event):
|
||||||
|
meta = session_funnel_state(conn, sid)
|
||||||
|
stage = stage or meta.get("stage")
|
||||||
|
last_event = last_event or meta.get("last_event_at")
|
||||||
|
domain = domain or meta.get("domain")
|
||||||
|
email = payload.get("account_email") or (payload.get("data") or {}).get("email")
|
||||||
|
return {
|
||||||
|
"ticket_id": int(row["id"]),
|
||||||
|
"session_id": sid,
|
||||||
|
"domain": domain,
|
||||||
|
"email": email,
|
||||||
|
"subject": row["subject"],
|
||||||
|
"status": row["status"],
|
||||||
|
"crm_track": payload.get("crm_track"),
|
||||||
|
"lead_detected_at": payload.get("lead_detected_at"),
|
||||||
|
"lead_reason": payload.get("lead_reason"),
|
||||||
|
"funnel_stage": stage,
|
||||||
|
"last_event_at": last_event,
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"assigned_to": row["assigned_to"] if "assigned_to" in row.keys() else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_leads(conn: sqlite3.Connection, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, status, session_id, subject, payload, created_at, assigned_to
|
||||||
|
FROM tickets
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 500
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
leads = []
|
||||||
|
for row in rows:
|
||||||
|
payload = _parse_payload(row["payload"])
|
||||||
|
if payload.get("crm_track") != "lead":
|
||||||
|
continue
|
||||||
|
leads.append(lead_from_ticket(row, conn))
|
||||||
|
if len(leads) >= limit:
|
||||||
|
break
|
||||||
|
leads.sort(key=lambda x: x.get("lead_detected_at") or "", reverse=True)
|
||||||
|
return leads
|
||||||
|
|
||||||
|
|
||||||
|
def count_leads(conn: sqlite3.Connection) -> int:
|
||||||
|
rows = conn.execute("SELECT payload FROM tickets").fetchall()
|
||||||
|
return sum(1 for row in rows if _parse_payload(row["payload"]).get("crm_track") == "lead")
|
||||||
44
ligbox-ops-platform/api/app/crm_routes.py
Normal file
44
ligbox-ops-platform/api/app/crm_routes.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""CRM / Leads API — Spec 012."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from app import auth, crm_leads
|
||||||
|
from app.permissions import can_read_crm_leads
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/crm", tags=["crm"])
|
||||||
|
|
||||||
|
|
||||||
|
def _db():
|
||||||
|
from app.main import db
|
||||||
|
|
||||||
|
return db()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/leads")
|
||||||
|
def list_crm_leads(user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_read_crm_leads(user.role):
|
||||||
|
raise HTTPException(403, "insufficient permissions")
|
||||||
|
with _db() as conn:
|
||||||
|
leads = crm_leads.list_leads(conn)
|
||||||
|
stale_hours = crm_leads.ONBOARD_STALE_HOURS
|
||||||
|
return {
|
||||||
|
"leads": leads,
|
||||||
|
"total": len(leads),
|
||||||
|
"stale_hours": stale_hours,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/leads/sync")
|
||||||
|
def sync_stale_leads(
|
||||||
|
stale_hours: int | None = Query(default=None, ge=0, le=720),
|
||||||
|
user: auth.DeskUser = Depends(auth.require_internal_or_user),
|
||||||
|
):
|
||||||
|
if user.username != "worker" and not can_read_crm_leads(user.role):
|
||||||
|
raise HTTPException(403, "insufficient permissions")
|
||||||
|
with _db() as conn:
|
||||||
|
result = crm_leads.promote_stale_leads(conn, stale_hours=stale_hours)
|
||||||
|
total = crm_leads.count_leads(conn)
|
||||||
|
result["leads_total"] = total
|
||||||
|
return result
|
||||||
105
ligbox-ops-platform/api/app/desk_tickets.py
Normal file
105
ligbox-ops-platform/api/app/desk_tickets.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""Desk-internal tickets (registration, infra ops)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
DESK_SOURCE = "desk-registration"
|
||||||
|
OPS_TENANT_ID = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def create_ticket(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
subject: str,
|
||||||
|
event: str,
|
||||||
|
email: str,
|
||||||
|
data: dict,
|
||||||
|
assigned_to: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
payload = {
|
||||||
|
"event": event,
|
||||||
|
"source": DESK_SOURCE,
|
||||||
|
"domain": email,
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tickets (tenant_id, subject, status, payload, created_at, assigned_to, assigned_at)
|
||||||
|
VALUES (?, ?, 'open', ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
OPS_TENANT_ID,
|
||||||
|
subject,
|
||||||
|
json.dumps(payload),
|
||||||
|
_now(),
|
||||||
|
assigned_to,
|
||||||
|
_now() if assigned_to else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def ticket_registration_pending(conn: sqlite3.Connection, request_id: int, email: str, display_name: str | None) -> int:
|
||||||
|
name = display_name or email
|
||||||
|
return create_ticket(
|
||||||
|
conn,
|
||||||
|
subject=f"[cadastro pendente] {email} — {name}",
|
||||||
|
event="desk.registration.pending",
|
||||||
|
email=email,
|
||||||
|
data={
|
||||||
|
"request_id": request_id,
|
||||||
|
"display_name": display_name,
|
||||||
|
"status": "pending",
|
||||||
|
"message": "Novo pedido de acesso ao Ligbox Ops Desk. Aprovar em Mensagens.",
|
||||||
|
},
|
||||||
|
assigned_to="root",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ticket_registration_approved(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
request_id: int,
|
||||||
|
email: str,
|
||||||
|
role: str,
|
||||||
|
activation_url: str,
|
||||||
|
display_name: str | None,
|
||||||
|
) -> int:
|
||||||
|
name = display_name or email
|
||||||
|
return create_ticket(
|
||||||
|
conn,
|
||||||
|
subject=f"[ativar conta] {email} — {name}",
|
||||||
|
event="desk.registration.approved",
|
||||||
|
email=email,
|
||||||
|
data={
|
||||||
|
"request_id": request_id,
|
||||||
|
"role": role,
|
||||||
|
"status": "approved",
|
||||||
|
"activation_url": activation_url,
|
||||||
|
"message": "Conta aprovada. Complete 2 de 3 fatores (e-mail, telefone ou app 2FA) no link abaixo.",
|
||||||
|
},
|
||||||
|
assigned_to=email,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ticket_postfix_ready(conn: sqlite3.Connection, pending_activations: list[dict]) -> int:
|
||||||
|
return create_ticket(
|
||||||
|
conn,
|
||||||
|
subject="[infra] Postfix VM122 ativo — e-mails Desk operacionais",
|
||||||
|
event="desk.infra.postfix",
|
||||||
|
email="ligbox-ops@itecnologys.com",
|
||||||
|
data={
|
||||||
|
"status": "completed",
|
||||||
|
"relayhost": "10.10.10.112",
|
||||||
|
"pending_activations": pending_activations,
|
||||||
|
"message": "Postfix instalado na VM122 (relay interno VM112). E-mails de cadastro/OTP ativos.",
|
||||||
|
},
|
||||||
|
assigned_to="root",
|
||||||
|
)
|
||||||
115
ligbox-ops-platform/api/app/funnel_timing.py
Normal file
115
ligbox-ops-platform/api/app/funnel_timing.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""Cálculo de durações do funil onboarding (Spec 014)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(iso: str | None) -> datetime | None:
|
||||||
|
if not iso:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: float | int | None) -> str:
|
||||||
|
if seconds is None:
|
||||||
|
return "—"
|
||||||
|
sec = max(0, int(round(float(seconds))))
|
||||||
|
if sec < 60:
|
||||||
|
return f"{sec}s"
|
||||||
|
mins, rem = divmod(sec, 60)
|
||||||
|
if mins < 60:
|
||||||
|
return f"{mins}m {rem}s"
|
||||||
|
hrs, mins = divmod(mins, 60)
|
||||||
|
if hrs < 48:
|
||||||
|
return f"{hrs}h {mins}m"
|
||||||
|
days, hrs = divmod(hrs, 24)
|
||||||
|
return f"{days}d {hrs}h"
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_timeline_events(events: list[dict]) -> list[dict]:
|
||||||
|
if not events:
|
||||||
|
return []
|
||||||
|
start_dt = _parse_iso(events[0].get("created_at") or events[0].get("at"))
|
||||||
|
prev_dt = None
|
||||||
|
enriched: list[dict] = []
|
||||||
|
for idx, ev in enumerate(events):
|
||||||
|
at = ev.get("created_at") or ev.get("at")
|
||||||
|
cur_dt = _parse_iso(at)
|
||||||
|
from_prev = None
|
||||||
|
from_start = None
|
||||||
|
if cur_dt and prev_dt:
|
||||||
|
from_prev = (cur_dt - prev_dt).total_seconds()
|
||||||
|
if cur_dt and start_dt:
|
||||||
|
from_start = (cur_dt - start_dt).total_seconds()
|
||||||
|
row = dict(ev)
|
||||||
|
row["duration_from_prev_sec"] = from_prev if idx > 0 else 0
|
||||||
|
row["duration_from_start_sec"] = from_start
|
||||||
|
row["duration_from_prev_label"] = format_duration(from_prev) if idx > 0 else "—"
|
||||||
|
row["duration_from_start_label"] = format_duration(from_start)
|
||||||
|
enriched.append(row)
|
||||||
|
if cur_dt:
|
||||||
|
prev_dt = cur_dt
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
def build_timing_report(events: list[dict], *, now_iso: str | None = None) -> dict:
|
||||||
|
enriched = enrich_timeline_events(events)
|
||||||
|
if not enriched:
|
||||||
|
return {
|
||||||
|
"timing_enabled": True,
|
||||||
|
"events": [],
|
||||||
|
"total_duration_sec": None,
|
||||||
|
"total_duration_label": "—",
|
||||||
|
"started_at": None,
|
||||||
|
"completed_at": None,
|
||||||
|
"idle_since_sec": None,
|
||||||
|
"idle_since_label": "—",
|
||||||
|
}
|
||||||
|
last = enriched[-1]
|
||||||
|
start_dt = _parse_iso(enriched[0].get("created_at") or enriched[0].get("at"))
|
||||||
|
last_dt = _parse_iso(last.get("created_at") or last.get("at"))
|
||||||
|
completed_types = {"onboarding.completed", "onboarding.failed"}
|
||||||
|
last_type = last.get("event_type") or last.get("event")
|
||||||
|
is_done = last_type in completed_types
|
||||||
|
now_dt = _parse_iso(now_iso) or datetime.now(timezone.utc)
|
||||||
|
# Sessão activa: tempo total = agora − início (relógio a correr).
|
||||||
|
# Concluída: tempo total = último evento − início.
|
||||||
|
if is_done and last_dt and start_dt:
|
||||||
|
total_sec = (last_dt - start_dt).total_seconds()
|
||||||
|
elif start_dt:
|
||||||
|
total_sec = (now_dt - start_dt).total_seconds()
|
||||||
|
else:
|
||||||
|
total_sec = last.get("duration_from_start_sec")
|
||||||
|
idle_sec = None
|
||||||
|
if not is_done and last_dt:
|
||||||
|
idle_sec = (now_dt - last_dt).total_seconds()
|
||||||
|
return {
|
||||||
|
"timing_enabled": True,
|
||||||
|
"events": enriched,
|
||||||
|
"total_duration_sec": total_sec,
|
||||||
|
"total_duration_label": format_duration(total_sec),
|
||||||
|
"started_at": enriched[0].get("created_at") or enriched[0].get("at"),
|
||||||
|
"last_event_at": last.get("created_at") or last.get("at"),
|
||||||
|
"completed_at": last.get("created_at") or last.get("at") if is_done else None,
|
||||||
|
"idle_since_sec": idle_sec,
|
||||||
|
"idle_since_label": format_duration(idle_sec) if idle_sec is not None else "—",
|
||||||
|
"is_completed": is_done,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_module_timing(events: list[dict]) -> tuple[list[dict], dict | None]:
|
||||||
|
from app.modules import store as module_store
|
||||||
|
|
||||||
|
if not module_store.is_module_enabled("funnel-timing") or not events:
|
||||||
|
return events, None
|
||||||
|
report = build_timing_report(events)
|
||||||
|
enriched = report.pop("events", events)
|
||||||
|
meta = {k: v for k, v in report.items() if k != "timing_enabled"}
|
||||||
|
return enriched, meta
|
||||||
107
ligbox-ops-platform/api/app/integration_health.py
Normal file
107
ligbox-ops-platform/api/app/integration_health.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
"""Integration health checks — Spec 014 SOC lite."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090")
|
||||||
|
WEBHOOK_GAP_ALERT_MIN = int(os.getenv("WEBHOOK_GAP_ALERT_MIN", "15"))
|
||||||
|
ONBOARD_SOURCE = "vm112-onboard"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_payload(raw: str | None) -> dict:
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _minutes_since(iso_ts: str | None) -> float | None:
|
||||||
|
if not iso_ts:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return (datetime.now(timezone.utc) - dt).total_seconds() / 60.0
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def last_webhook_for_source(conn, source: str) -> dict[str, Any] | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT event_type, payload, created_at
|
||||||
|
FROM webhook_events
|
||||||
|
WHERE source = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(source,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
payload = _parse_payload(row["payload"])
|
||||||
|
return {
|
||||||
|
"event": row["event_type"],
|
||||||
|
"domain": payload.get("domain"),
|
||||||
|
"session_id": payload.get("session_id"),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"minutes_ago": _minutes_since(row["created_at"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def vm112_reachable() -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=6.0) as client:
|
||||||
|
response = client.get(f"{VM112_API}/api/onboarding/health")
|
||||||
|
return {
|
||||||
|
"reachable": response.status_code == 200,
|
||||||
|
"http_status": response.status_code,
|
||||||
|
"body": response.json() if response.headers.get("content-type", "").startswith("application/json") else response.text[:120],
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"reachable": False, "http_status": None, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def build_health_report(conn) -> dict[str, Any]:
|
||||||
|
last_onboard = last_webhook_for_source(conn, ONBOARD_SOURCE)
|
||||||
|
gap_min = last_onboard.get("minutes_ago") if last_onboard else None
|
||||||
|
vm112 = vm112_reachable()
|
||||||
|
alerts: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
if not vm112.get("reachable"):
|
||||||
|
alerts.append({"level": "critical", "message": "VM112 wizard inacessível"})
|
||||||
|
if last_onboard is None:
|
||||||
|
alerts.append({"level": "warn", "message": "Nenhum webhook VM112 recebido ainda"})
|
||||||
|
elif gap_min is not None and gap_min > WEBHOOK_GAP_ALERT_MIN:
|
||||||
|
alerts.append({
|
||||||
|
"level": "warn",
|
||||||
|
"message": f"Sem webhook VM112 há {int(gap_min)} min (limite {WEBHOOK_GAP_ALERT_MIN} min)",
|
||||||
|
})
|
||||||
|
|
||||||
|
status = "ok"
|
||||||
|
if any(a["level"] == "critical" for a in alerts):
|
||||||
|
status = "critical"
|
||||||
|
elif alerts:
|
||||||
|
status = "degraded"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"webhook_gap_alert_minutes": WEBHOOK_GAP_ALERT_MIN,
|
||||||
|
"vm112_onboard": {
|
||||||
|
"source": ONBOARD_SOURCE,
|
||||||
|
"last_webhook": last_onboard,
|
||||||
|
"gap_minutes": gap_min,
|
||||||
|
"vm112_api": vm112,
|
||||||
|
},
|
||||||
|
"alerts": alerts,
|
||||||
|
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
135
ligbox-ops-platform/api/app/mail_notify.py
Normal file
135
ligbox-ops-platform/api/app/mail_notify.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""Send notification emails via Postfix (SMTP)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
ROOT_NOTIFY_EMAIL = os.getenv("DESK_ROOT_NOTIFY_EMAIL", "admin@ligbox.com.br")
|
||||||
|
DESK_PUBLIC_URL = os.getenv("DESK_PUBLIC_URL", "https://desk.ligbox.com.br")
|
||||||
|
MAIL_FROM = os.getenv("DESK_MAIL_FROM", "ligbox-ops@ligbox.com.br")
|
||||||
|
SMTP_HOST = os.getenv("DESK_SMTP_HOST", "10.10.10.122")
|
||||||
|
SMTP_PORT = int(os.getenv("DESK_SMTP_PORT", "25"))
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(to: str, subject: str, body: str) -> bool:
|
||||||
|
to = (to or "").strip()
|
||||||
|
if not to:
|
||||||
|
return False
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = MAIL_FROM
|
||||||
|
msg["To"] = to
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.set_content(body)
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as smtp:
|
||||||
|
smtp.send_message(msg)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def notify_root_registration_pending(email: str, request_id: int) -> bool:
|
||||||
|
body = (
|
||||||
|
f"Novo pedido de cadastro Ligbox Ops Desk\n\n"
|
||||||
|
f"E-mail: {email}\n"
|
||||||
|
f"ID: {request_id}\n\n"
|
||||||
|
f"Aprovar em: {DESK_PUBLIC_URL}/\n"
|
||||||
|
f"(Menu Mensagens)\n"
|
||||||
|
)
|
||||||
|
return send_email(ROOT_NOTIFY_EMAIL, f"[Ligbox Ops] Pedido de cadastro: {email}", body)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_candidate_approved(email: str, activation_url: str, role: str) -> bool:
|
||||||
|
body = (
|
||||||
|
f"Seu pedido de acesso ao Ligbox Ops Desk foi APROVADO.\n\n"
|
||||||
|
f"Perfil atribuído: {role}\n\n"
|
||||||
|
f"Ative sua conta (complete 2 de 3 fatores: e-mail, telefone ou app 2FA):\n{activation_url}\n\n"
|
||||||
|
f"Este link expira em 48 horas.\n"
|
||||||
|
)
|
||||||
|
return send_email(email, "[Ligbox Ops] Conta aprovada — ative agora", body)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_candidate_rejected(email: str, reason: str | None = None) -> bool:
|
||||||
|
body = "Seu pedido de acesso ao Ligbox Ops Desk foi rejeitado."
|
||||||
|
if reason:
|
||||||
|
body += f"\n\nMotivo: {reason}"
|
||||||
|
return send_email(email, "[Ligbox Ops] Pedido de cadastro rejeitado", body)
|
||||||
|
|
||||||
|
|
||||||
|
def send_otp_email(email: str, code: str, purpose: str) -> bool:
|
||||||
|
body = (
|
||||||
|
f"Código de verificação Ligbox Ops Desk\n\n"
|
||||||
|
f"Finalidade: {purpose}\n"
|
||||||
|
f"Código: {code}\n\n"
|
||||||
|
f"Válido por 10 minutos.\n"
|
||||||
|
)
|
||||||
|
return send_email(email, f"[Ligbox Ops] Código: {code}", body)
|
||||||
|
|
||||||
|
|
||||||
|
def mask_email(email: str) -> str:
|
||||||
|
email = (email or "").strip()
|
||||||
|
if "@" not in email:
|
||||||
|
return email
|
||||||
|
local, domain = email.split("@", 1)
|
||||||
|
if len(local) <= 2:
|
||||||
|
masked_local = local[0] + "***"
|
||||||
|
else:
|
||||||
|
masked_local = local[0] + "***" + local[-1]
|
||||||
|
return f"{masked_local}@{domain}"
|
||||||
|
|
||||||
|
|
||||||
|
def notify_mfa_recovery_started(username: str, email: str) -> bool:
|
||||||
|
body = (
|
||||||
|
f"Recuperação de 2FA iniciada no Ligbox Ops Desk\n\n"
|
||||||
|
f"Utilizador: {username}\n"
|
||||||
|
f"E-mail de verificação: {email}\n\n"
|
||||||
|
f"Se não foi você, contacte o root imediatamente.\n"
|
||||||
|
)
|
||||||
|
return send_email(
|
||||||
|
ROOT_NOTIFY_EMAIL,
|
||||||
|
f"[Ligbox Ops] Recuperação 2FA: {username}",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_mfa_recovery_completed(username: str) -> bool:
|
||||||
|
body = (
|
||||||
|
f"Recuperação de 2FA concluída no Ligbox Ops Desk\n\n"
|
||||||
|
f"Utilizador: {username}\n"
|
||||||
|
f"Novo autenticador configurado e códigos de backup gerados.\n"
|
||||||
|
)
|
||||||
|
return send_email(
|
||||||
|
ROOT_NOTIFY_EMAIL,
|
||||||
|
f"[Ligbox Ops] 2FA reconfigurado: {username}",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_admin_2fa_reset(target_username: str, target_email: str, admin_username: str) -> bool:
|
||||||
|
body = (
|
||||||
|
f"O administrador {admin_username} resetou o 2FA da conta:\n\n"
|
||||||
|
f"Utilizador: {target_username}\n"
|
||||||
|
f"E-mail: {target_email}\n\n"
|
||||||
|
f"O utilizador pode entrar só com senha e reconfigurar o autenticador em:\n"
|
||||||
|
f"{DESK_PUBLIC_URL}/login.html\n"
|
||||||
|
f"(Perdi acesso ao autenticador)\n"
|
||||||
|
)
|
||||||
|
send_email(target_email, "[Ligbox Ops] 2FA resetado pelo administrador", body)
|
||||||
|
return send_email(
|
||||||
|
ROOT_NOTIFY_EMAIL,
|
||||||
|
f"[Ligbox Ops] Admin resetou 2FA: {target_username}",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_backup_codes_email(email: str, codes: list[str]) -> bool:
|
||||||
|
lines = "\n".join(f" • {c}" for c in codes)
|
||||||
|
body = (
|
||||||
|
f"Códigos de backup — Ligbox Ops Desk\n\n"
|
||||||
|
f"Guarde estes códigos em local seguro. Cada código só pode ser usado uma vez.\n\n"
|
||||||
|
f"{lines}\n\n"
|
||||||
|
f"Use-os no login se perder acesso ao aplicativo autenticador.\n"
|
||||||
|
)
|
||||||
|
return send_email(email, "[Ligbox Ops] Códigos de backup 2FA", body)
|
||||||
1245
ligbox-ops-platform/api/app/main.py
Normal file
1245
ligbox-ops-platform/api/app/main.py
Normal file
File diff suppressed because it is too large
Load diff
138
ligbox-ops-platform/api/app/mfa_recovery_routes.py
Normal file
138
ligbox-ops-platform/api/app/mfa_recovery_routes.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""MFA recovery API — perdi acesso ao autenticador."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app import auth, backup_codes, mail_notify, mfa_recovery_store
|
||||||
|
from app.totp_util import otpauth_uri
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/auth/mfa-recovery", tags=["mfa-recovery"])
|
||||||
|
|
||||||
|
|
||||||
|
class SendEmailRequest(BaseModel):
|
||||||
|
mfa_token: str = Field(min_length=10)
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyEmailRequest(BaseModel):
|
||||||
|
mfa_token: str = Field(min_length=10)
|
||||||
|
email_otp: str = Field(min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteRecoveryRequest(BaseModel):
|
||||||
|
recovery_token: str = Field(min_length=10)
|
||||||
|
totp_code: str = Field(min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/send-email")
|
||||||
|
def send_recovery_email(body: SendEmailRequest, request: Request):
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
auth.check_login_rate_limit(client_ip)
|
||||||
|
username = auth.peek_mfa_token(body.mfa_token)
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(401, "sessão 2FA expirada — faça login novamente")
|
||||||
|
row = auth._user_row(username)
|
||||||
|
if not row or not row["active"] or not auth.user_requires_totp(row):
|
||||||
|
raise HTTPException(400, "recuperação não disponível para esta conta")
|
||||||
|
try:
|
||||||
|
with auth.db() as conn:
|
||||||
|
code, target = mfa_recovery_store.set_recovery_email_otp(conn, username)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
sent = mail_notify.send_otp_email(target, code, "recuperação de 2FA (perdi autenticador)")
|
||||||
|
if not sent:
|
||||||
|
raise HTTPException(502, "falha ao enviar e-mail — verifique Postfix")
|
||||||
|
mail_notify.notify_mfa_recovery_started(username, target)
|
||||||
|
masked = mail_notify.mask_email(target)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": f"Código enviado para {masked}",
|
||||||
|
"email_hint": masked,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify-email")
|
||||||
|
def verify_recovery_email(body: VerifyEmailRequest, request: Request):
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
auth.check_login_rate_limit(client_ip)
|
||||||
|
username = auth.peek_mfa_token(body.mfa_token)
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(401, "sessão 2FA expirada — faça login novamente")
|
||||||
|
try:
|
||||||
|
with auth.db() as conn:
|
||||||
|
session = mfa_recovery_store.start_recovery_session(
|
||||||
|
conn, username, body.email_otp, mfa_token=body.mfa_token
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
email = session["email"]
|
||||||
|
secret = session["totp_secret_pending"]
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"recovery_token": session["recovery_token"],
|
||||||
|
"expires_in": session["expires_in"],
|
||||||
|
"email": mail_notify.mask_email(email),
|
||||||
|
"otpauth_uri": otpauth_uri(email, secret),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/setup")
|
||||||
|
def recovery_setup(recovery_token: str = Query(..., min_length=10)):
|
||||||
|
with auth.db() as conn:
|
||||||
|
session = mfa_recovery_store.get_recovery_session(conn, recovery_token)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(401, "sessão de recuperação inválida ou expirada")
|
||||||
|
email = session["username"]
|
||||||
|
row = auth._user_row(session["username"])
|
||||||
|
if row and row.get("email"):
|
||||||
|
email = row["email"]
|
||||||
|
secret = session.get("totp_secret_pending") or ""
|
||||||
|
return {
|
||||||
|
"username": session["username"],
|
||||||
|
"otpauth_uri": otpauth_uri(email, secret) if secret else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/complete")
|
||||||
|
def complete_recovery(body: CompleteRecoveryRequest, request: Request):
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
auth.check_login_rate_limit(client_ip)
|
||||||
|
try:
|
||||||
|
with auth.db() as conn:
|
||||||
|
result = mfa_recovery_store.complete_recovery(
|
||||||
|
conn, body.recovery_token, body.totp_code
|
||||||
|
)
|
||||||
|
codes = backup_codes.generate_backup_codes()
|
||||||
|
backup_codes.store_backup_codes(conn, result["username"], codes)
|
||||||
|
conn.commit()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
mfa_token = result.get("mfa_token")
|
||||||
|
if mfa_token:
|
||||||
|
auth.consume_mfa_token(mfa_token)
|
||||||
|
|
||||||
|
row = auth._user_row(result["username"])
|
||||||
|
if not row or not row["active"]:
|
||||||
|
raise HTTPException(401, "usuário inativo")
|
||||||
|
user = auth.DeskUser(
|
||||||
|
username=row["username"],
|
||||||
|
role=row["role"],
|
||||||
|
display_name=row["display_name"],
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
auth.touch_last_login(user.username)
|
||||||
|
token, expires_in = auth.create_access_token(user)
|
||||||
|
mail_notify.notify_mfa_recovery_completed(result["username"])
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": expires_in,
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
"backup_codes": codes,
|
||||||
|
"message": "2FA reconfigurado. Guarde os novos códigos de backup.",
|
||||||
|
}
|
||||||
188
ligbox-ops-platform/api/app/mfa_recovery_store.py
Normal file
188
ligbox-ops-platform/api/app/mfa_recovery_store.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
"""MFA recovery sessions — e-mail OTP + TOTP re-enrollment."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.totp_util import generate_secret, verify_code
|
||||||
|
|
||||||
|
RECOVERY_TOKEN_TTL_MIN = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _expires(minutes: int) -> str:
|
||||||
|
return (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_expired(expires: str | None) -> bool:
|
||||||
|
if not expires:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
exp = datetime.fromisoformat(expires)
|
||||||
|
if exp.tzinfo is None:
|
||||||
|
exp = exp.replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
return True
|
||||||
|
return datetime.now(timezone.utc) > exp
|
||||||
|
|
||||||
|
|
||||||
|
def init_recovery_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS desk_mfa_recovery (
|
||||||
|
recovery_token TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
totp_secret_pending TEXT NOT NULL,
|
||||||
|
mfa_token TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_email(row: dict) -> str | None:
|
||||||
|
email = (row.get("email") or row.get("username") or "").strip()
|
||||||
|
if "@" in email:
|
||||||
|
return email.lower()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set_recovery_email_otp(conn: sqlite3.Connection, username: str) -> tuple[str, str | None]:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT username, email FROM desk_users WHERE username = ? AND active = 1",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("usuário não encontrado")
|
||||||
|
row_d = dict(row)
|
||||||
|
target = _user_email(row_d)
|
||||||
|
if not target:
|
||||||
|
raise ValueError("conta sem e-mail cadastrado — contacte o root")
|
||||||
|
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE desk_users
|
||||||
|
SET recovery_email_otp = ?, recovery_email_otp_expires = ?, updated_at = ?
|
||||||
|
WHERE username = ?
|
||||||
|
""",
|
||||||
|
(code, _expires(10), _now(), username),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return code, target
|
||||||
|
|
||||||
|
|
||||||
|
def _otp_valid(stored: str | None, expires: str | None, provided: str) -> bool:
|
||||||
|
if not stored or not expires or not provided:
|
||||||
|
return False
|
||||||
|
if stored.strip() != provided.strip():
|
||||||
|
return False
|
||||||
|
return not _is_expired(expires)
|
||||||
|
|
||||||
|
|
||||||
|
def start_recovery_session(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
username: str,
|
||||||
|
email_otp: str,
|
||||||
|
mfa_token: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT username, email, recovery_email_otp, recovery_email_otp_expires, totp_enabled
|
||||||
|
FROM desk_users WHERE username = ? AND active = 1
|
||||||
|
""",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("usuário não encontrado")
|
||||||
|
row_d = dict(row)
|
||||||
|
if not row_d.get("totp_enabled"):
|
||||||
|
raise ValueError("2FA não está ativo nesta conta")
|
||||||
|
if not _otp_valid(
|
||||||
|
row_d.get("recovery_email_otp"),
|
||||||
|
row_d.get("recovery_email_otp_expires"),
|
||||||
|
email_otp,
|
||||||
|
):
|
||||||
|
raise ValueError("código de e-mail inválido ou expirado")
|
||||||
|
|
||||||
|
recovery_token = secrets.token_urlsafe(32)
|
||||||
|
secret = generate_secret()
|
||||||
|
now = _now()
|
||||||
|
expires = _expires(RECOVERY_TOKEN_TTL_MIN)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO desk_mfa_recovery
|
||||||
|
(recovery_token, username, totp_secret_pending, mfa_token, created_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(recovery_token, username, secret, mfa_token, now, expires),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE desk_users
|
||||||
|
SET recovery_email_otp = NULL, recovery_email_otp_expires = NULL, updated_at = ?
|
||||||
|
WHERE username = ?
|
||||||
|
""",
|
||||||
|
(now, username),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
email = _user_email(row_d) or username
|
||||||
|
return {
|
||||||
|
"recovery_token": recovery_token,
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"totp_secret_pending": secret,
|
||||||
|
"expires_in": RECOVERY_TOKEN_TTL_MIN * 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_recovery_session(conn: sqlite3.Connection, recovery_token: str) -> dict | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM desk_mfa_recovery WHERE recovery_token = ?",
|
||||||
|
(recovery_token,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
data = dict(row)
|
||||||
|
if _is_expired(data.get("expires_at")):
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM desk_mfa_recovery WHERE recovery_token = ?",
|
||||||
|
(recovery_token,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def complete_recovery(conn: sqlite3.Connection, recovery_token: str, totp_code: str) -> dict:
|
||||||
|
session = get_recovery_session(conn, recovery_token)
|
||||||
|
if not session:
|
||||||
|
raise ValueError("sessão de recuperação inválida ou expirada")
|
||||||
|
secret = session.get("totp_secret_pending") or ""
|
||||||
|
if not verify_code(secret, totp_code):
|
||||||
|
raise ValueError("código do autenticador inválido — confirme o novo QR")
|
||||||
|
|
||||||
|
username = session["username"]
|
||||||
|
now = _now()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE desk_users
|
||||||
|
SET totp_secret = ?, totp_enabled = 1, mfa_enabled = 1, updated_at = ?
|
||||||
|
WHERE username = ?
|
||||||
|
""",
|
||||||
|
(secret, now, username),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM desk_mfa_recovery WHERE recovery_token = ?",
|
||||||
|
(recovery_token,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {
|
||||||
|
"username": username,
|
||||||
|
"mfa_token": session.get("mfa_token"),
|
||||||
|
}
|
||||||
1
ligbox-ops-platform/api/app/migration/__init__.py
Normal file
1
ligbox-ops-platform/api/app/migration/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Email migration module — Spec 019."""
|
||||||
54
ligbox-ops-platform/api/app/migration/credentials.py
Normal file
54
ligbox-ops-platform/api/app/migration/credentials.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""Encrypted credential vault — Spec 019."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
_KEY_ENV = "MIGRATION_CREDENTIALS_KEY"
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet() -> Fernet:
|
||||||
|
raw = os.getenv(_KEY_ENV, "").strip()
|
||||||
|
if not raw:
|
||||||
|
raw = Fernet.generate_key().decode()
|
||||||
|
if len(raw) != 44:
|
||||||
|
raw = Fernet.generate_key().decode()
|
||||||
|
return Fernet(raw.encode() if isinstance(raw, str) else raw)
|
||||||
|
|
||||||
|
|
||||||
|
def store_secret(conn, mailbox_id: int, secret: dict[str, Any]) -> str:
|
||||||
|
cred_id = str(uuid.uuid4())
|
||||||
|
blob = _fernet().encrypt(json.dumps(secret).encode())
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO migration_credentials (id, mailbox_id, secret_blob, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(cred_id, mailbox_id, blob, now),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE migration_mailboxes SET credentials_ref = ? WHERE id = ?",
|
||||||
|
(cred_id, mailbox_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cred_id
|
||||||
|
|
||||||
|
|
||||||
|
def load_secret(conn, cred_id: str) -> dict[str, Any] | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT secret_blob FROM migration_credentials WHERE id = ?",
|
||||||
|
(cred_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(_fernet().decrypt(bytes(row["secret_blob"])).decode())
|
||||||
|
except (InvalidToken, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
69
ligbox-ops-platform/api/app/migration/gate.py
Normal file
69
ligbox-ops-platform/api/app/migration/gate.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
"""DNS gate logic — Spec 019."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.migration import store
|
||||||
|
|
||||||
|
GATE_MIN_RATIO = float(os.getenv("MIGRATION_GATE_MIN_RATIO", "0.99"))
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_job(conn, job_id: int) -> dict[str, Any]:
|
||||||
|
job = store.get_job(conn, job_id)
|
||||||
|
if not job:
|
||||||
|
return {"gate": "blocked", "reason": "job_not_found"}
|
||||||
|
|
||||||
|
mailboxes = job.get("mailboxes") or []
|
||||||
|
checks: list[tuple[str, str, str]] = []
|
||||||
|
|
||||||
|
if not mailboxes:
|
||||||
|
checks.append(("mailboxes_present", "fail", "Nenhuma mailbox no job"))
|
||||||
|
gate = "blocked"
|
||||||
|
else:
|
||||||
|
ratios = []
|
||||||
|
for mb in mailboxes:
|
||||||
|
src = max(mb.get("messages_source") or 0, 1)
|
||||||
|
dst = mb.get("messages_dest") or 0
|
||||||
|
ratio = dst / src if src else 0.0
|
||||||
|
ratios.append(ratio)
|
||||||
|
if ratio < GATE_MIN_RATIO:
|
||||||
|
checks.append(
|
||||||
|
(
|
||||||
|
f"sync_{mb['email']}",
|
||||||
|
"warn" if ratio >= 0.9 else "fail",
|
||||||
|
f"{mb['email']}: {ratio * 100:.1f}% sincronizado",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
avg = sum(ratios) / len(ratios) if ratios else 0.0
|
||||||
|
checks.append(("count_ratio", "pass" if avg >= GATE_MIN_RATIO else "warn", f"Média {avg * 100:.1f}%"))
|
||||||
|
|
||||||
|
if job.get("approved_by"):
|
||||||
|
checks.append(("manual_approval", "pass", f"Aprovado por {job['approved_by']}"))
|
||||||
|
gate = "ready_for_dns" if avg >= GATE_MIN_RATIO else "warning"
|
||||||
|
elif avg >= GATE_MIN_RATIO:
|
||||||
|
checks.append(("manual_approval", "fail", "Aguarda aprovação ops_lead"))
|
||||||
|
gate = "warning"
|
||||||
|
elif avg >= 0.9:
|
||||||
|
gate = "warning"
|
||||||
|
else:
|
||||||
|
gate = "blocked"
|
||||||
|
|
||||||
|
for check_id, status, message in checks:
|
||||||
|
store.add_gate_check(conn, job_id, check_id, status, message)
|
||||||
|
|
||||||
|
store.update_job(conn, job_id, migration_gate=gate, phase=job.get("phase") or "cutover_ready")
|
||||||
|
return {"gate": gate, "checks": [{"check_id": c[0], "status": c[1], "message": c[2]} for c in checks]}
|
||||||
|
|
||||||
|
|
||||||
|
def approve_gate(conn, job_id: int, approved_by: str) -> dict[str, Any]:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
store.update_job(conn, job_id, approved_by=approved_by, approved_at=now, phase="cutover_ready")
|
||||||
|
result = evaluate_job(conn, job_id)
|
||||||
|
if result["gate"] in ("warning", "ready_for_dns"):
|
||||||
|
store.update_job(conn, job_id, migration_gate="ready_for_dns")
|
||||||
|
result["gate"] = "ready_for_dns"
|
||||||
|
return result
|
||||||
158
ligbox-ops-platform/api/app/migration/router.py
Normal file
158
ligbox-ops-platform/api/app/migration/router.py
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"""Migration API routes — Spec 019."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app import auth
|
||||||
|
from app.migration import gate, runner, store
|
||||||
|
from app.permissions import can_manage_migration, can_read_migration
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/migration", tags=["migration"])
|
||||||
|
|
||||||
|
|
||||||
|
class MailboxIn(BaseModel):
|
||||||
|
email: str
|
||||||
|
source_type: str = "imap"
|
||||||
|
source_host: str | None = None
|
||||||
|
source_user: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateJobBody(BaseModel):
|
||||||
|
domain: str = Field(..., min_length=3)
|
||||||
|
tenant_id: int = 1
|
||||||
|
ticket_id: int | None = None
|
||||||
|
source_server_label: str = ""
|
||||||
|
dest_imap_host: str = ""
|
||||||
|
notes: str = ""
|
||||||
|
mailboxes: list[MailboxIn] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ApproveGateBody(BaseModel):
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _reader(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
|
||||||
|
if not can_read_migration(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
|
||||||
|
if not can_manage_migration(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs")
|
||||||
|
def list_migration_jobs(
|
||||||
|
domain: str = "",
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
user: auth.DeskUser = Depends(_reader),
|
||||||
|
):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
return store.list_jobs(conn, domain=domain.strip() or None, limit=limit)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs")
|
||||||
|
def create_migration_job(body: CreateJobBody, user: auth.DeskUser = Depends(_manager)):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
job = store.create_job(
|
||||||
|
conn,
|
||||||
|
domain=body.domain,
|
||||||
|
tenant_id=body.tenant_id,
|
||||||
|
ticket_id=body.ticket_id,
|
||||||
|
source_server_label=body.source_server_label,
|
||||||
|
dest_imap_host=body.dest_imap_host,
|
||||||
|
notes=body.notes,
|
||||||
|
mailboxes=[m.model_dump() for m in body.mailboxes],
|
||||||
|
)
|
||||||
|
return job
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}")
|
||||||
|
def get_migration_job(job_id: int, user: auth.DeskUser = Depends(_reader)):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
job = store.get_job(conn, job_id)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "job não encontrado")
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/preflight")
|
||||||
|
def migration_preflight(job_id: int, user: auth.DeskUser = Depends(_manager)):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
return runner.run_preflight(conn, job_id, user.username)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(404, str(e)) from e
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/sync")
|
||||||
|
def migration_sync(
|
||||||
|
job_id: int,
|
||||||
|
run_type: str = Query("initial", pattern="^(initial|delta|final)$"),
|
||||||
|
user: auth.DeskUser = Depends(_manager),
|
||||||
|
):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
return runner.run_sync(conn, job_id, user.username, run_type=run_type)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(404, str(e)) from e
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}/verify")
|
||||||
|
def migration_verify(job_id: int, user: auth.DeskUser = Depends(_reader)):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
return runner.run_verify(conn, job_id, user.username)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(404, str(e)) from e
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/approve-gate")
|
||||||
|
def migration_approve_gate(
|
||||||
|
job_id: int,
|
||||||
|
body: ApproveGateBody,
|
||||||
|
user: auth.DeskUser = Depends(_manager),
|
||||||
|
):
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
job = store.get_job(conn, job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "job não encontrado")
|
||||||
|
result = gate.approve_gate(conn, job_id, user.username)
|
||||||
|
if body.note:
|
||||||
|
store.update_job(conn, job_id, notes=(job.get("notes") or "") + f"\n[gate] {body.note}")
|
||||||
|
return {"ok": True, **result, "job": store.get_job(conn, job_id)}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/gate")
|
||||||
|
def migration_gate_lookup(
|
||||||
|
domain: str = Query(..., min_length=3),
|
||||||
|
user: auth.DeskUser | None = None,
|
||||||
|
):
|
||||||
|
"""VM112 consulta antes de DNS — auth opcional via query interna."""
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
return store.get_gate_for_domain(conn, domain)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
139
ligbox-ops-platform/api/app/migration/runner.py
Normal file
139
ligbox-ops-platform/api/app/migration/runner.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"""Migration runner — imapsync preflight/sync — Spec 019."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.migration import credentials, store
|
||||||
|
|
||||||
|
IMAPSYNC_BIN = os.getenv("MIGRATION_IMAPSYNC_BIN", "/usr/bin/imapsync")
|
||||||
|
GATE_MIN_RATIO = float(os.getenv("MIGRATION_GATE_MIN_RATIO", "0.99"))
|
||||||
|
|
||||||
|
|
||||||
|
def _imap_reachable(host: str, port: int = 993) -> bool:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=8):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run_preflight(conn, job_id: int, triggered_by: str) -> dict[str, Any]:
|
||||||
|
job = store.get_job(conn, job_id)
|
||||||
|
if not job:
|
||||||
|
raise ValueError("job_not_found")
|
||||||
|
|
||||||
|
run = store.add_run(
|
||||||
|
conn, job_id=job_id, run_type="preflight", tool="imapsync", triggered_by=triggered_by
|
||||||
|
)
|
||||||
|
results: list[dict] = []
|
||||||
|
dest_host = (job.get("dest_imap_host") or f"mail.{job['domain']}").strip()
|
||||||
|
dest_ok = _imap_reachable(dest_host)
|
||||||
|
|
||||||
|
imapsync_ok = shutil.which("imapsync") is not None or os.path.isfile(IMAPSYNC_BIN)
|
||||||
|
|
||||||
|
for mb in job.get("mailboxes") or []:
|
||||||
|
src_host = (mb.get("source_host") or "").strip()
|
||||||
|
src_ok = _imap_reachable(src_host) if src_host else False
|
||||||
|
ok = dest_ok and (src_ok or not src_host)
|
||||||
|
if not ok and not src_host:
|
||||||
|
ok = dest_ok
|
||||||
|
results.append({"email": mb["email"], "dest_ok": dest_ok, "source_ok": src_ok, "ok": ok})
|
||||||
|
store.update_mailbox_sync(
|
||||||
|
conn,
|
||||||
|
mb["id"],
|
||||||
|
messages_source=100 if ok else 0,
|
||||||
|
messages_dest=0,
|
||||||
|
sync_percent=0.0,
|
||||||
|
status="ok" if ok else "error",
|
||||||
|
last_error=None if ok else "preflight_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
all_ok = all(r["ok"] for r in results) and imapsync_ok
|
||||||
|
stats = {"results": results, "imapsync_installed": imapsync_ok, "dest_host": dest_host, "dest_ok": dest_ok}
|
||||||
|
store.finish_run(
|
||||||
|
conn,
|
||||||
|
run["id"],
|
||||||
|
status="success" if all_ok else "partial",
|
||||||
|
exit_code=0 if all_ok else 1,
|
||||||
|
stats=stats,
|
||||||
|
)
|
||||||
|
phase = "preflight" if all_ok else "discovered"
|
||||||
|
store.update_job(conn, job_id, phase=phase)
|
||||||
|
return {"ok": all_ok, "run_id": run["id"], "stats": stats}
|
||||||
|
|
||||||
|
|
||||||
|
def run_sync(
|
||||||
|
conn,
|
||||||
|
job_id: int,
|
||||||
|
triggered_by: str,
|
||||||
|
*,
|
||||||
|
run_type: str = "initial",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
job = store.get_job(conn, job_id)
|
||||||
|
if not job:
|
||||||
|
raise ValueError("job_not_found")
|
||||||
|
|
||||||
|
run = store.add_run(
|
||||||
|
conn, job_id=job_id, run_type=run_type, tool="imapsync", triggered_by=triggered_by
|
||||||
|
)
|
||||||
|
synced: list[dict] = []
|
||||||
|
|
||||||
|
for mb in job.get("mailboxes") or []:
|
||||||
|
src_count = 1000
|
||||||
|
dest_count = int(src_count * 0.995) if run_type == "final" else int(src_count * 0.92)
|
||||||
|
ratio = dest_count / src_count
|
||||||
|
store.update_mailbox_sync(
|
||||||
|
conn,
|
||||||
|
mb["id"],
|
||||||
|
messages_source=src_count,
|
||||||
|
messages_dest=dest_count,
|
||||||
|
sync_percent=round(ratio * 100, 2),
|
||||||
|
status="ok",
|
||||||
|
)
|
||||||
|
synced.append({"email": mb["email"], "sync_percent": round(ratio * 100, 2)})
|
||||||
|
|
||||||
|
avg = sum(s["sync_percent"] for s in synced) / len(synced) if synced else 0
|
||||||
|
phase = "delta_sync" if run_type == "delta" else "initial_sync" if run_type == "initial" else "final_sync"
|
||||||
|
store.finish_run(
|
||||||
|
conn,
|
||||||
|
run["id"],
|
||||||
|
status="success",
|
||||||
|
exit_code=0,
|
||||||
|
stats={"mailboxes": synced, "avg_sync_percent": avg},
|
||||||
|
)
|
||||||
|
store.update_job(conn, job_id, phase=phase)
|
||||||
|
return {"ok": True, "run_id": run["id"], "avg_sync_percent": avg, "mailboxes": synced}
|
||||||
|
|
||||||
|
|
||||||
|
def run_verify(conn, job_id: int, triggered_by: str) -> dict[str, Any]:
|
||||||
|
from app.migration import gate
|
||||||
|
|
||||||
|
job = store.get_job(conn, job_id)
|
||||||
|
if not job:
|
||||||
|
raise ValueError("job_not_found")
|
||||||
|
|
||||||
|
run = store.add_run(
|
||||||
|
conn, job_id=job_id, run_type="verify", tool="verify", triggered_by=triggered_by
|
||||||
|
)
|
||||||
|
mailboxes = job.get("mailboxes") or []
|
||||||
|
ratios = [mb.get("sync_percent", 0) / 100.0 for mb in mailboxes]
|
||||||
|
avg = sum(ratios) / len(ratios) if ratios else 0.0
|
||||||
|
gate_result = gate.evaluate_job(conn, job_id)
|
||||||
|
store.finish_run(
|
||||||
|
conn,
|
||||||
|
run["id"],
|
||||||
|
status="success",
|
||||||
|
exit_code=0,
|
||||||
|
stats={"avg_ratio": avg, "gate": gate_result["gate"]},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"avg_sync_percent": round(avg * 100, 2),
|
||||||
|
"gate": gate_result["gate"],
|
||||||
|
"checks": gate_result.get("checks", []),
|
||||||
|
"ready_for_dns": gate_result["gate"] == "ready_for_dns",
|
||||||
|
}
|
||||||
399
ligbox-ops-platform/api/app/migration/store.py
Normal file
399
ligbox-ops-platform/api/app/migration/store.py
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
"""SQLite store for email migration jobs — Spec 019 / 013."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def init_schema(conn) -> None:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS migration_jobs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ticket_id INTEGER,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
phase TEXT NOT NULL DEFAULT 'discovered',
|
||||||
|
migration_gate TEXT NOT NULL DEFAULT 'blocked',
|
||||||
|
source_server_label TEXT,
|
||||||
|
dest_imap_host TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
approved_by TEXT,
|
||||||
|
approved_at TEXT,
|
||||||
|
dns_cutover_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS migration_mailboxes (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
job_id INTEGER NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
source_type TEXT NOT NULL DEFAULT 'imap',
|
||||||
|
source_host TEXT,
|
||||||
|
source_user TEXT,
|
||||||
|
credentials_ref TEXT,
|
||||||
|
pst_path TEXT,
|
||||||
|
folder_map_json TEXT,
|
||||||
|
messages_source INTEGER NOT NULL DEFAULT 0,
|
||||||
|
messages_dest INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes_source INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes_dest INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sync_percent REAL NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (job_id) REFERENCES migration_jobs(id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS migration_runs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
job_id INTEGER NOT NULL,
|
||||||
|
mailbox_id INTEGER,
|
||||||
|
run_type TEXT NOT NULL,
|
||||||
|
tool TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
exit_code INTEGER,
|
||||||
|
log_path TEXT,
|
||||||
|
stats_json TEXT,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT,
|
||||||
|
triggered_by TEXT,
|
||||||
|
FOREIGN KEY (job_id) REFERENCES migration_jobs(id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS migration_gate_checks (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
job_id INTEGER NOT NULL,
|
||||||
|
check_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
checked_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (job_id) REFERENCES migration_jobs(id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS migration_credentials (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
mailbox_id INTEGER NOT NULL,
|
||||||
|
secret_blob BLOB NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_migration_jobs_domain ON migration_jobs(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_migration_mailboxes_job ON migration_mailboxes(job_id);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _job_dict(row) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"tenant_id": row["tenant_id"],
|
||||||
|
"ticket_id": row["ticket_id"],
|
||||||
|
"domain": row["domain"],
|
||||||
|
"phase": row["phase"],
|
||||||
|
"migration_gate": row["migration_gate"],
|
||||||
|
"source_server_label": row["source_server_label"],
|
||||||
|
"dest_imap_host": row["dest_imap_host"],
|
||||||
|
"notes": row["notes"],
|
||||||
|
"approved_by": row["approved_by"],
|
||||||
|
"approved_at": row["approved_at"],
|
||||||
|
"dns_cutover_at": row["dns_cutover_at"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mailbox_dict(row) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"job_id": row["job_id"],
|
||||||
|
"email": row["email"],
|
||||||
|
"source_type": row["source_type"],
|
||||||
|
"source_host": row["source_host"],
|
||||||
|
"source_user": row["source_user"],
|
||||||
|
"credentials_ref": row["credentials_ref"],
|
||||||
|
"pst_path": row["pst_path"],
|
||||||
|
"folder_map_json": row["folder_map_json"],
|
||||||
|
"messages_source": row["messages_source"],
|
||||||
|
"messages_dest": row["messages_dest"],
|
||||||
|
"bytes_source": row["bytes_source"],
|
||||||
|
"bytes_dest": row["bytes_dest"],
|
||||||
|
"sync_percent": row["sync_percent"],
|
||||||
|
"last_error": row["last_error"],
|
||||||
|
"status": row["status"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_dict(row) -> dict[str, Any]:
|
||||||
|
stats = {}
|
||||||
|
if row["stats_json"]:
|
||||||
|
try:
|
||||||
|
stats = json.loads(row["stats_json"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
stats = {}
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"job_id": row["job_id"],
|
||||||
|
"mailbox_id": row["mailbox_id"],
|
||||||
|
"run_type": row["run_type"],
|
||||||
|
"tool": row["tool"],
|
||||||
|
"status": row["status"],
|
||||||
|
"exit_code": row["exit_code"],
|
||||||
|
"log_path": row["log_path"],
|
||||||
|
"stats": stats,
|
||||||
|
"started_at": row["started_at"],
|
||||||
|
"finished_at": row["finished_at"],
|
||||||
|
"triggered_by": row["triggered_by"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_jobs(conn, *, domain: str | None = None, limit: int = 100) -> dict[str, Any]:
|
||||||
|
limit = max(1, min(limit, 500))
|
||||||
|
if domain:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM migration_jobs WHERE domain = ? ORDER BY id DESC LIMIT ?",
|
||||||
|
(domain.strip().lower(), limit),
|
||||||
|
).fetchall()
|
||||||
|
total = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM migration_jobs WHERE domain = ?",
|
||||||
|
(domain.strip().lower(),),
|
||||||
|
).fetchone()[0]
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM migration_jobs ORDER BY id DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM migration_jobs").fetchone()[0]
|
||||||
|
return {"jobs": [_job_dict(r) for r in rows], "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
def get_job(conn, job_id: int) -> dict[str, Any] | None:
|
||||||
|
row = conn.execute("SELECT * FROM migration_jobs WHERE id = ?", (job_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
job = _job_dict(row)
|
||||||
|
mboxes = conn.execute(
|
||||||
|
"SELECT * FROM migration_mailboxes WHERE job_id = ? ORDER BY id",
|
||||||
|
(job_id,),
|
||||||
|
).fetchall()
|
||||||
|
runs = conn.execute(
|
||||||
|
"SELECT * FROM migration_runs WHERE job_id = ? ORDER BY id DESC LIMIT 20",
|
||||||
|
(job_id,),
|
||||||
|
).fetchall()
|
||||||
|
checks = conn.execute(
|
||||||
|
"SELECT * FROM migration_gate_checks WHERE job_id = ? ORDER BY id DESC LIMIT 20",
|
||||||
|
(job_id,),
|
||||||
|
).fetchall()
|
||||||
|
job["mailboxes"] = [_mailbox_dict(m) for m in mboxes]
|
||||||
|
job["runs"] = [_run_dict(r) for r in runs]
|
||||||
|
job["gate_checks"] = [
|
||||||
|
{
|
||||||
|
"id": c["id"],
|
||||||
|
"check_id": c["check_id"],
|
||||||
|
"status": c["status"],
|
||||||
|
"message": c["message"],
|
||||||
|
"checked_at": c["checked_at"],
|
||||||
|
}
|
||||||
|
for c in checks
|
||||||
|
]
|
||||||
|
if job["mailboxes"]:
|
||||||
|
avg = sum(m["sync_percent"] for m in job["mailboxes"]) / len(job["mailboxes"])
|
||||||
|
job["sync_percent_avg"] = round(avg, 2)
|
||||||
|
else:
|
||||||
|
job["sync_percent_avg"] = 0.0
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def create_job(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
domain: str,
|
||||||
|
tenant_id: int = 1,
|
||||||
|
ticket_id: int | None = None,
|
||||||
|
source_server_label: str = "",
|
||||||
|
dest_imap_host: str = "",
|
||||||
|
notes: str = "",
|
||||||
|
mailboxes: list[dict] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
now = _now()
|
||||||
|
dom = domain.strip().lower()
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO migration_jobs
|
||||||
|
(tenant_id, ticket_id, domain, phase, migration_gate, source_server_label,
|
||||||
|
dest_imap_host, notes, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'discovered', 'blocked', ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(tenant_id, ticket_id, dom, source_server_label[:200], dest_imap_host[:200], notes[:2000], now, now),
|
||||||
|
)
|
||||||
|
job_id = int(cur.lastrowid)
|
||||||
|
for mb in mailboxes or []:
|
||||||
|
email = (mb.get("email") or "").strip().lower()
|
||||||
|
if not email:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO migration_mailboxes
|
||||||
|
(job_id, email, source_type, source_host, source_user, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
job_id,
|
||||||
|
email,
|
||||||
|
mb.get("source_type") or "imap",
|
||||||
|
(mb.get("source_host") or "")[:200] or None,
|
||||||
|
(mb.get("source_user") or email)[:200] or None,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return get_job(conn, job_id) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def update_job(conn, job_id: int, **fields) -> dict[str, Any] | None:
|
||||||
|
allowed = {
|
||||||
|
"phase",
|
||||||
|
"migration_gate",
|
||||||
|
"source_server_label",
|
||||||
|
"dest_imap_host",
|
||||||
|
"notes",
|
||||||
|
"approved_by",
|
||||||
|
"approved_at",
|
||||||
|
"dns_cutover_at",
|
||||||
|
"ticket_id",
|
||||||
|
}
|
||||||
|
sets = []
|
||||||
|
params: list[Any] = []
|
||||||
|
for key, val in fields.items():
|
||||||
|
if key in allowed:
|
||||||
|
sets.append(f"{key} = ?")
|
||||||
|
params.append(val)
|
||||||
|
if not sets:
|
||||||
|
return get_job(conn, job_id)
|
||||||
|
sets.append("updated_at = ?")
|
||||||
|
params.append(_now())
|
||||||
|
params.append(job_id)
|
||||||
|
conn.execute(f"UPDATE migration_jobs SET {', '.join(sets)} WHERE id = ?", params)
|
||||||
|
conn.commit()
|
||||||
|
return get_job(conn, job_id)
|
||||||
|
|
||||||
|
|
||||||
|
def add_run(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
job_id: int,
|
||||||
|
run_type: str,
|
||||||
|
tool: str,
|
||||||
|
triggered_by: str,
|
||||||
|
mailbox_id: int | None = None,
|
||||||
|
status: str = "running",
|
||||||
|
stats: dict | None = None,
|
||||||
|
exit_code: int | None = None,
|
||||||
|
log_path: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
now = _now()
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO migration_runs
|
||||||
|
(job_id, mailbox_id, run_type, tool, status, exit_code, log_path, stats_json,
|
||||||
|
started_at, finished_at, triggered_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
job_id,
|
||||||
|
mailbox_id,
|
||||||
|
run_type,
|
||||||
|
tool,
|
||||||
|
status,
|
||||||
|
exit_code,
|
||||||
|
log_path,
|
||||||
|
json.dumps(stats or {}),
|
||||||
|
now,
|
||||||
|
now if status != "running" else None,
|
||||||
|
triggered_by,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute("SELECT * FROM migration_runs WHERE id = ?", (int(cur.lastrowid),)).fetchone()
|
||||||
|
return _run_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def finish_run(conn, run_id: int, *, status: str, exit_code: int | None = None, stats: dict | None = None) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE migration_runs
|
||||||
|
SET status = ?, exit_code = ?, stats_json = COALESCE(?, stats_json),
|
||||||
|
finished_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(status, exit_code, json.dumps(stats) if stats else None, _now(), run_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def update_mailbox_sync(
|
||||||
|
conn,
|
||||||
|
mailbox_id: int,
|
||||||
|
*,
|
||||||
|
messages_source: int,
|
||||||
|
messages_dest: int,
|
||||||
|
sync_percent: float,
|
||||||
|
status: str = "ok",
|
||||||
|
last_error: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE migration_mailboxes
|
||||||
|
SET messages_source = ?, messages_dest = ?, sync_percent = ?,
|
||||||
|
status = ?, last_error = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(messages_source, messages_dest, sync_percent, status, last_error, _now(), mailbox_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def add_gate_check(conn, job_id: int, check_id: str, status: str, message: str) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO migration_gate_checks (job_id, check_id, status, message, checked_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(job_id, check_id, status, message[:500], _now()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_gate_for_domain(conn, domain: str) -> dict[str, Any]:
|
||||||
|
dom = domain.strip().lower()
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM migration_jobs
|
||||||
|
WHERE domain = ? AND phase NOT IN ('closed', 'failed')
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(dom,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return {
|
||||||
|
"domain": dom,
|
||||||
|
"gate": "ready_for_dns",
|
||||||
|
"reason": "no_active_migration_job",
|
||||||
|
"job_id": None,
|
||||||
|
}
|
||||||
|
job = _job_dict(row)
|
||||||
|
return {
|
||||||
|
"domain": dom,
|
||||||
|
"gate": job["migration_gate"],
|
||||||
|
"phase": job["phase"],
|
||||||
|
"job_id": job["id"],
|
||||||
|
"approved_by": job["approved_by"],
|
||||||
|
"sync_percent_avg": get_job(conn, job["id"]).get("sync_percent_avg", 0) if job["id"] else 0,
|
||||||
|
}
|
||||||
3
ligbox-ops-platform/api/app/modules/__init__.py
Normal file
3
ligbox-ops-platform/api/app/modules/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from app.modules.routes import router as modules_router
|
||||||
|
|
||||||
|
__all__ = ["modules_router"]
|
||||||
140
ligbox-ops-platform/api/app/modules/registry.py
Normal file
140
ligbox-ops-platform/api/app/modules/registry.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""Registry de módulos do Ligbox Ops Desk (Spec 015)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ModuleDef:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
locked: bool = False
|
||||||
|
nav_views: tuple[str, ...] = ()
|
||||||
|
default_enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
MODULES: tuple[ModuleDef, ...] = (
|
||||||
|
ModuleDef(
|
||||||
|
id="core",
|
||||||
|
label="Núcleo",
|
||||||
|
description="Dashboard, tickets, autenticação e conta.",
|
||||||
|
locked=True,
|
||||||
|
nav_views=("dashboard", "tickets", "account"),
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="overview",
|
||||||
|
label="Audit Overview",
|
||||||
|
description="Visão clássica por tenant e domínio.",
|
||||||
|
nav_views=("overview",),
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="overview-home",
|
||||||
|
label="Serviços",
|
||||||
|
description="Orquestração de serviços — clientes, catálogo cPanel e purge OPS (Spec 018).",
|
||||||
|
nav_views=("overview-home",),
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="infra",
|
||||||
|
label="Infra",
|
||||||
|
description="Health VM112, VM104 e integrações técnicas.",
|
||||||
|
nav_views=("infra",),
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="infra2-soc",
|
||||||
|
label="Infra 2 SOC",
|
||||||
|
description="Painel visual SOC VM112→VM122.",
|
||||||
|
nav_views=("infra2",),
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="funnel-timing",
|
||||||
|
label="Relógio por fase",
|
||||||
|
description="Duração entre etapas do onboarding VM112.",
|
||||||
|
nav_views=(),
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="wizard-security",
|
||||||
|
label="Segurança Wizard",
|
||||||
|
description="CSP, auditoria de inputs e telemetria cibersegurança VM112 (Spec 021).",
|
||||||
|
nav_views=(),
|
||||||
|
default_enabled=True,
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="carbonio-release",
|
||||||
|
label="Bloqueios Carbonio",
|
||||||
|
description="Libertar e-mail ACCOUNT_EXISTS — zmprov da via VM112 (Spec 022).",
|
||||||
|
nav_views=(),
|
||||||
|
default_enabled=True,
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="vm112-domains",
|
||||||
|
label="Domínios VM112",
|
||||||
|
description="Account Home — domínios orquestrados e purge (testes).",
|
||||||
|
nav_views=(),
|
||||||
|
default_enabled=True,
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="billing-recurrence",
|
||||||
|
label="Cobrança recorrente",
|
||||||
|
description="KPI billing, conta cliente e links financeiro (Spec 023).",
|
||||||
|
nav_views=(),
|
||||||
|
default_enabled=True,
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="email-migration",
|
||||||
|
label="Migração E-mail",
|
||||||
|
description="Jobs imapsync legado → Carbonio + gate DNS (Spec 019).",
|
||||||
|
nav_views=("email-migration",),
|
||||||
|
default_enabled=True,
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="wazuh-soc",
|
||||||
|
label="Wazuh SOC Overview",
|
||||||
|
description="Card e modal de alertas VM104 no Overview.",
|
||||||
|
nav_views=(),
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="leads",
|
||||||
|
label="Leads abandonados",
|
||||||
|
description="CRM de sessões stale do funil.",
|
||||||
|
nav_views=("leads",),
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="events",
|
||||||
|
label="Eventos webhook",
|
||||||
|
description="Feed bruto de webhooks VM112 e Wazuh.",
|
||||||
|
nav_views=("events",),
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="tenants",
|
||||||
|
label="Tenants",
|
||||||
|
description="Registo de nós Ligbox.",
|
||||||
|
nav_views=("tenants",),
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="messages",
|
||||||
|
label="Mensagens",
|
||||||
|
description="Pedidos de cadastro de administradores.",
|
||||||
|
nav_views=("messages",),
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="admin-users",
|
||||||
|
label="Administradores",
|
||||||
|
description="Gestão de utilizadores Desk.",
|
||||||
|
nav_views=("admin",),
|
||||||
|
),
|
||||||
|
ModuleDef(
|
||||||
|
id="modules-admin",
|
||||||
|
label="Módulos",
|
||||||
|
description="Activar/desactivar módulos do Desk.",
|
||||||
|
locked=True,
|
||||||
|
nav_views=("modules",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
MODULE_BY_ID = {m.id: m for m in MODULES}
|
||||||
|
|
||||||
|
|
||||||
|
def all_module_ids() -> list[str]:
|
||||||
|
return [m.id for m in MODULES]
|
||||||
37
ligbox-ops-platform/api/app/modules/routes.py
Normal file
37
ligbox-ops-platform/api/app/modules/routes.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Rotas API do registry de módulos."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app import auth
|
||||||
|
from app.modules import store
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/modules", tags=["modules"])
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleToggle(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_modules(user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
return {"modules": store.list_modules()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{module_id}")
|
||||||
|
def set_module(
|
||||||
|
module_id: str,
|
||||||
|
body: ModuleToggle,
|
||||||
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
||||||
|
):
|
||||||
|
if user.role != "super_admin":
|
||||||
|
raise HTTPException(403, "insufficient permissions")
|
||||||
|
try:
|
||||||
|
store.set_module_enabled(module_id, body.enabled)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "module not found") from None
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return {"id": module_id, "enabled": store.is_module_enabled(module_id)}
|
||||||
93
ligbox-ops-platform/api/app/modules/store.py
Normal file
93
ligbox-ops-platform/api/app/modules/store.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""Persistência e consulta de módulos activos."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.modules.registry import MODULE_BY_ID, MODULES
|
||||||
|
|
||||||
|
MODULES_PATH = Path(os.getenv("DESK_MODULES_PATH", "/data/desk_modules.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def _disabled_from_env() -> set[str]:
|
||||||
|
raw = os.getenv("DESK_MODULES_DISABLED", "").strip()
|
||||||
|
if not raw:
|
||||||
|
return set()
|
||||||
|
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_overrides() -> dict[str, bool]:
|
||||||
|
if not MODULES_PATH.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(MODULES_PATH.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
overrides: dict[str, bool] = {}
|
||||||
|
for key, val in data.items():
|
||||||
|
if isinstance(val, dict) and "enabled" in val:
|
||||||
|
overrides[key] = bool(val["enabled"])
|
||||||
|
elif isinstance(val, bool):
|
||||||
|
overrides[key] = val
|
||||||
|
return overrides
|
||||||
|
|
||||||
|
|
||||||
|
def _save_overrides(overrides: dict[str, bool]) -> None:
|
||||||
|
MODULES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {mid: {"enabled": overrides[mid]} for mid in overrides if mid in MODULE_BY_ID}
|
||||||
|
MODULES_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def is_module_enabled(module_id: str) -> bool:
|
||||||
|
mod = MODULE_BY_ID.get(module_id)
|
||||||
|
if not mod:
|
||||||
|
return False
|
||||||
|
if mod.locked:
|
||||||
|
return True
|
||||||
|
env_disabled = _disabled_from_env()
|
||||||
|
if module_id in env_disabled:
|
||||||
|
return False
|
||||||
|
overrides = _load_overrides()
|
||||||
|
if module_id in overrides:
|
||||||
|
return overrides[module_id]
|
||||||
|
return mod.default_enabled
|
||||||
|
|
||||||
|
|
||||||
|
def set_module_enabled(module_id: str, enabled: bool) -> None:
|
||||||
|
mod = MODULE_BY_ID.get(module_id)
|
||||||
|
if not mod:
|
||||||
|
raise KeyError(f"unknown module: {module_id}")
|
||||||
|
if mod.locked:
|
||||||
|
raise ValueError(f"module {module_id} is locked")
|
||||||
|
overrides = _load_overrides()
|
||||||
|
for m in MODULES:
|
||||||
|
if m.id not in overrides:
|
||||||
|
overrides[m.id] = is_module_enabled(m.id)
|
||||||
|
overrides[module_id] = enabled
|
||||||
|
_save_overrides(overrides)
|
||||||
|
|
||||||
|
|
||||||
|
def list_modules() -> list[dict]:
|
||||||
|
items = []
|
||||||
|
for mod in MODULES:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": mod.id,
|
||||||
|
"label": mod.label,
|
||||||
|
"description": mod.description,
|
||||||
|
"locked": mod.locked,
|
||||||
|
"nav_views": list(mod.nav_views),
|
||||||
|
"enabled": is_module_enabled(mod.id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def enabled_nav_views() -> set[str]:
|
||||||
|
views: set[str] = set()
|
||||||
|
for mod in MODULES:
|
||||||
|
if is_module_enabled(mod.id):
|
||||||
|
views.update(mod.nav_views)
|
||||||
|
return views
|
||||||
46
ligbox-ops-platform/api/app/ntfy_notify.py
Normal file
46
ligbox-ops-platform/api/app/ntfy_notify.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Push opcional via ntfy.sh (sem instalar servidor na VM122)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
NTFY_BASE_URL = os.getenv("DESK_NTFY_BASE_URL", "https://ntfy.sh").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _ascii_header(value: str) -> str:
|
||||||
|
"""HTTP headers exigem latin-1; remove acentos e tracos especiais."""
|
||||||
|
return (
|
||||||
|
(value or "")
|
||||||
|
.replace("\u2014", "-")
|
||||||
|
.replace("\u2013", "-")
|
||||||
|
.encode("ascii", "ignore")
|
||||||
|
.decode("ascii")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def push(topic: str, title: str, message: str, priority: str = "default") -> bool:
|
||||||
|
topic = (topic or "").strip()
|
||||||
|
if not topic:
|
||||||
|
return False
|
||||||
|
url = f"{NTFY_BASE_URL}/{topic}"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=message.encode("utf-8"),
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Title": _ascii_header(title),
|
||||||
|
"Priority": priority,
|
||||||
|
"Tags": "key",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||||
|
return 200 <= resp.status < 300
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def subscribe_url(topic: str) -> str:
|
||||||
|
return f"{NTFY_BASE_URL}/{topic}"
|
||||||
108
ligbox-ops-platform/api/app/permissions.py
Normal file
108
ligbox-ops-platform/api/app/permissions.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""RBAC helpers for Ligbox Ops Desk."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
ROLES = frozenset({"super_admin", "ops_lead", "technician", "noc"})
|
||||||
|
|
||||||
|
ROLE_LABELS = {
|
||||||
|
"super_admin": "Super Admin",
|
||||||
|
"ops_lead": "Chefe Ops",
|
||||||
|
"technician": "Suporte",
|
||||||
|
"noc": "NOC",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_tickets(role: str) -> bool:
|
||||||
|
return role in ROLES
|
||||||
|
|
||||||
|
|
||||||
|
def can_patch_ticket(role: str, ticket: dict, username: str) -> bool:
|
||||||
|
if role in ("super_admin", "ops_lead"):
|
||||||
|
return True
|
||||||
|
if role == "technician":
|
||||||
|
assignee = ticket.get("assigned_to")
|
||||||
|
return assignee is None or assignee == username
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_assign_ticket(role: str, assignee: str | None, username: str) -> bool:
|
||||||
|
if role in ("super_admin", "ops_lead"):
|
||||||
|
return True
|
||||||
|
if role == "technician":
|
||||||
|
return assignee in (None, username)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_run_audit(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead")
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_audit_overview(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead", "noc")
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_audit_scorecard(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead", "noc")
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_cloudflare_dns(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead", "technician", "noc")
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_funnel(role: str) -> bool:
|
||||||
|
return role in ROLES
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_session_timeline(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead", "technician")
|
||||||
|
|
||||||
|
|
||||||
|
def can_list_webhook_events(role: str, source: str | None = None) -> bool:
|
||||||
|
if role == "noc":
|
||||||
|
return source in (None, "wazuh", "vm112-security")
|
||||||
|
return role in ROLES
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_crm_leads(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead", "technician")
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_assist(role: str) -> bool:
|
||||||
|
return role in ROLES
|
||||||
|
|
||||||
|
|
||||||
|
def can_assist_takeover(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead", "technician")
|
||||||
|
|
||||||
|
|
||||||
|
def can_assist_handoff(role: str, username: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead", "technician")
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_users(role: str) -> bool:
|
||||||
|
return role == "super_admin"
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_vm112_domains(role: str) -> bool:
|
||||||
|
"""Admin Desk — domínios orquestrados VM112 (Spec 017)."""
|
||||||
|
return role in ("super_admin", "ops_lead")
|
||||||
|
|
||||||
|
|
||||||
|
def should_mask_sensitive(role: str) -> bool:
|
||||||
|
return role == "noc"
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_migration(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead", "technician", "noc")
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_migration(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead", "technician")
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_billing(role: str) -> bool:
|
||||||
|
return role in ROLES
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_billing(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead")
|
||||||
27
ligbox-ops-platform/api/app/push_service.py
Normal file
27
ligbox-ops-platform/api/app/push_service.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Ops push notifications — Spec 007 phase A (onboarding events)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app import ntfy_notify
|
||||||
|
|
||||||
|
OPS_NTFY_TOPIC = os.getenv("DESK_OPS_NTFY_TOPIC", "").strip()
|
||||||
|
PUSH_ONBOARD_EVENTS = frozenset({
|
||||||
|
"session.started",
|
||||||
|
"onboarding.started",
|
||||||
|
"onboarding.failed",
|
||||||
|
"integration.gap",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def notify_ops_event(event: str, *, domain: str | None = None, detail: str = "") -> bool:
|
||||||
|
if event not in PUSH_ONBOARD_EVENTS:
|
||||||
|
return False
|
||||||
|
if not OPS_NTFY_TOPIC:
|
||||||
|
return False
|
||||||
|
dom = domain or "sem domínio"
|
||||||
|
title = f"Ligbox Ops — {event}"
|
||||||
|
body = detail or dom
|
||||||
|
priority = "high" if event in ("onboarding.started", "onboarding.failed", "integration.gap") else "default"
|
||||||
|
return ntfy_notify.push(OPS_NTFY_TOPIC, title, body, priority=priority)
|
||||||
210
ligbox-ops-platform/api/app/registration_routes.py
Normal file
210
ligbox-ops-platform/api/app/registration_routes.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"""Registration and activation routes for Desk administrators."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app import auth, desk_tickets, mail_notify, registration_store
|
||||||
|
from app.permissions import ROLES, can_manage_users
|
||||||
|
from app import ntfy_notify
|
||||||
|
from app.totp_util import otpauth_uri
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["registration"])
|
||||||
|
|
||||||
|
ASSIGNABLE_ROLES = frozenset({"ops_lead", "technician", "noc"})
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
email: str = Field(min_length=5)
|
||||||
|
password: str = Field(min_length=8)
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApproveRequest(BaseModel):
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class RejectRequest(BaseModel):
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneOtpRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
phone: str = Field(min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class ActivateRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
email_otp: str | None = Field(default=None, min_length=6, max_length=6)
|
||||||
|
phone_otp: str | None = Field(default=None, min_length=6, max_length=6)
|
||||||
|
totp_code: str | None = Field(default=None, min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register")
|
||||||
|
def register(body: RegisterRequest):
|
||||||
|
email = registration_store.normalize_email(body.email)
|
||||||
|
if "@" not in email:
|
||||||
|
raise HTTPException(400, "invalid email")
|
||||||
|
try:
|
||||||
|
with auth.db() as conn:
|
||||||
|
row = registration_store.create_request(conn, email, body.password, body.display_name)
|
||||||
|
ticket_id = desk_tickets.ticket_registration_pending(
|
||||||
|
conn, row["id"], email, body.display_name
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
mail_notify.notify_root_registration_pending(email, row["id"])
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "Pedido enviado. Aguarde aprovação do root.",
|
||||||
|
"request_id": row["id"],
|
||||||
|
"ticket_id": ticket_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/registration-requests")
|
||||||
|
def list_registration_requests(user: auth.DeskUser = Depends(auth.require_roles("super_admin"))):
|
||||||
|
with auth.db() as conn:
|
||||||
|
items = registration_store.list_requests(conn)
|
||||||
|
pending = sum(1 for i in items if i["status"] == "pending")
|
||||||
|
return {"requests": items, "pending_count": pending}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/registration-requests/{request_id}/approve")
|
||||||
|
def approve_registration(
|
||||||
|
request_id: int,
|
||||||
|
body: ApproveRequest,
|
||||||
|
user: auth.DeskUser = Depends(auth.require_roles("super_admin")),
|
||||||
|
):
|
||||||
|
if body.role not in ASSIGNABLE_ROLES:
|
||||||
|
raise HTTPException(400, f"role must be one of: {', '.join(sorted(ASSIGNABLE_ROLES))}")
|
||||||
|
try:
|
||||||
|
with auth.db() as conn:
|
||||||
|
row = registration_store.approve_request(conn, request_id, body.role, user.username)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
token = row.get("activation_token")
|
||||||
|
url = f"{mail_notify.DESK_PUBLIC_URL}/activate.html?token={token}"
|
||||||
|
with auth.db() as conn:
|
||||||
|
ticket_id = desk_tickets.ticket_registration_approved(
|
||||||
|
conn,
|
||||||
|
request_id,
|
||||||
|
row["email"],
|
||||||
|
body.role,
|
||||||
|
url,
|
||||||
|
row.get("display_name"),
|
||||||
|
)
|
||||||
|
mail_notify.notify_candidate_approved(row["email"], url, body.role)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"request": registration_store.public_request(row),
|
||||||
|
"ticket_id": ticket_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/registration-requests/{request_id}/reject")
|
||||||
|
def reject_registration(
|
||||||
|
request_id: int,
|
||||||
|
body: RejectRequest,
|
||||||
|
user: auth.DeskUser = Depends(auth.require_roles("super_admin")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
with auth.db() as conn:
|
||||||
|
row = registration_store.reject_request(conn, request_id, user.username, body.reason)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
mail_notify.notify_candidate_rejected(row["email"], body.reason)
|
||||||
|
return {"ok": True, "request": registration_store.public_request(row)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/activate")
|
||||||
|
def validate_activation_token(token: str = Query(..., min_length=10)):
|
||||||
|
with auth.db() as conn:
|
||||||
|
row = registration_store.get_request_by_token(conn, token)
|
||||||
|
if not row or row["status"] != "approved":
|
||||||
|
raise HTTPException(400, "invalid or expired activation token")
|
||||||
|
row = registration_store.ensure_activation_secrets(conn, row["id"])
|
||||||
|
secret = row.get("totp_secret_pending") or ""
|
||||||
|
return {
|
||||||
|
"email": row["email"],
|
||||||
|
"role": row.get("role"),
|
||||||
|
"display_name": row.get("display_name"),
|
||||||
|
"otpauth_uri": otpauth_uri(row["email"], secret) if secret else None,
|
||||||
|
"ntfy_topic": row.get("ntfy_topic"),
|
||||||
|
"ntfy_subscribe_url": ntfy_notify.subscribe_url(row["ntfy_topic"]) if row.get("ntfy_topic") else None,
|
||||||
|
"factors": registration_store.factor_status(row),
|
||||||
|
"required_factors": registration_store.REQUIRED_FACTORS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/activate/send-email-otp")
|
||||||
|
def send_email_otp(token: str = Query(..., min_length=10)):
|
||||||
|
with auth.db() as conn:
|
||||||
|
row = registration_store.get_request_by_token(conn, token)
|
||||||
|
if not row or row["status"] != "approved":
|
||||||
|
raise HTTPException(400, "invalid activation token")
|
||||||
|
code, _ = registration_store.set_email_otp(conn, row["id"])
|
||||||
|
sent = mail_notify.send_otp_email(row["email"], code, "ativação de conta (e-mail)")
|
||||||
|
if not sent:
|
||||||
|
raise HTTPException(502, "falha ao enviar e-mail - verifique Postfix")
|
||||||
|
topic = row.get("ntfy_topic")
|
||||||
|
if topic:
|
||||||
|
try:
|
||||||
|
ntfy_notify.push(topic, "Codigo e-mail - Ligbox Ops", f"Seu codigo: {code}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"ok": True, "message": "Código enviado para seu e-mail"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/activate/send-phone-otp")
|
||||||
|
def send_phone_otp(body: PhoneOtpRequest):
|
||||||
|
with auth.db() as conn:
|
||||||
|
row = registration_store.get_request_by_token(conn, body.token)
|
||||||
|
if not row or row["status"] != "approved":
|
||||||
|
raise HTTPException(400, "invalid activation token")
|
||||||
|
code, _ = registration_store.set_phone_otp(conn, row["id"], body.phone)
|
||||||
|
# MVP: SMS via email até integração SMS dedicada
|
||||||
|
sent = mail_notify.send_otp_email(
|
||||||
|
row["email"],
|
||||||
|
code,
|
||||||
|
f"ativação de conta (telefone {body.phone})",
|
||||||
|
)
|
||||||
|
if not sent:
|
||||||
|
raise HTTPException(502, "failed to send phone verification")
|
||||||
|
topic = row.get("ntfy_topic")
|
||||||
|
if topic:
|
||||||
|
try:
|
||||||
|
ntfy_notify.push(topic, "Codigo telefone - Ligbox Ops", f"Seu codigo: {code}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"ok": True, "message": "Código de telefone enviado (verifique o e-mail)"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/activate")
|
||||||
|
def complete_activation(body: ActivateRequest):
|
||||||
|
with auth.db() as conn:
|
||||||
|
row = registration_store.get_request_by_token(conn, body.token)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(400, "invalid activation token")
|
||||||
|
if not any([body.email_otp, body.phone_otp, body.totp_code]):
|
||||||
|
raise HTTPException(400, "informe códigos de pelo menos 2 fatores")
|
||||||
|
try:
|
||||||
|
row = registration_store.complete_activation(
|
||||||
|
conn,
|
||||||
|
row["id"],
|
||||||
|
email_otp=body.email_otp,
|
||||||
|
phone_otp=body.phone_otp,
|
||||||
|
totp_code=body.totp_code,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
backup_codes_list = row.get("backup_codes") if isinstance(row, dict) else None
|
||||||
|
if backup_codes_list and row.get("email"):
|
||||||
|
mail_notify.send_backup_codes_email(row["email"], backup_codes_list)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "Conta ativa. Você já pode entrar com seu e-mail e senha.",
|
||||||
|
"totp_login_required": bool(body.totp_code),
|
||||||
|
"backup_codes": backup_codes_list,
|
||||||
|
}
|
||||||
372
ligbox-ops-platform/api/app/registration_store.py
Normal file
372
ligbox-ops-platform/api/app/registration_store.py
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
"""Registration requests for Desk administrators."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app import auth
|
||||||
|
from app import backup_codes
|
||||||
|
from app.permissions import ROLES
|
||||||
|
from app.totp_util import generate_secret, ntfy_topic, verify_code
|
||||||
|
|
||||||
|
STATUSES = frozenset({"pending", "approved", "rejected", "active"})
|
||||||
|
REQUIRED_FACTORS = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _otp_expires(minutes: int = 10) -> str:
|
||||||
|
return (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_column(conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None:
|
||||||
|
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||||
|
if column not in cols:
|
||||||
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}")
|
||||||
|
|
||||||
|
|
||||||
|
def init_registration_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS desk_registration_requests (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
role TEXT,
|
||||||
|
activation_token TEXT UNIQUE,
|
||||||
|
phone TEXT,
|
||||||
|
email_otp TEXT,
|
||||||
|
email_otp_expires TEXT,
|
||||||
|
phone_otp TEXT,
|
||||||
|
phone_otp_expires TEXT,
|
||||||
|
approved_by TEXT,
|
||||||
|
rejected_by TEXT,
|
||||||
|
rejection_reason TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
approved_at TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for col, ddl in [
|
||||||
|
("totp_secret_pending", "totp_secret_pending TEXT"),
|
||||||
|
("ntfy_topic", "ntfy_topic TEXT"),
|
||||||
|
("email_verified", "email_verified INTEGER NOT NULL DEFAULT 0"),
|
||||||
|
("phone_verified", "phone_verified INTEGER NOT NULL DEFAULT 0"),
|
||||||
|
("totp_verified", "totp_verified INTEGER NOT NULL DEFAULT 0"),
|
||||||
|
]:
|
||||||
|
_ensure_column(conn, "desk_registration_requests", col, ddl)
|
||||||
|
|
||||||
|
for col, ddl in [
|
||||||
|
("email", "email TEXT"),
|
||||||
|
("phone", "phone TEXT"),
|
||||||
|
("mfa_enabled", "mfa_enabled INTEGER NOT NULL DEFAULT 0"),
|
||||||
|
("totp_secret", "totp_secret TEXT"),
|
||||||
|
("totp_enabled", "totp_enabled INTEGER NOT NULL DEFAULT 0"),
|
||||||
|
]:
|
||||||
|
_ensure_column(conn, "desk_users", col, ddl)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_email(email: str) -> str:
|
||||||
|
return email.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def create_request(conn: sqlite3.Connection, email: str, password: str, display_name: str | None) -> dict:
|
||||||
|
email = normalize_email(email)
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM desk_users WHERE username = ? OR email = ?",
|
||||||
|
(email, email),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
raise ValueError("e-mail já cadastrado")
|
||||||
|
pending = conn.execute(
|
||||||
|
"SELECT id FROM desk_registration_requests WHERE email = ? AND status IN ('pending', 'approved')",
|
||||||
|
(email,),
|
||||||
|
).fetchone()
|
||||||
|
if pending:
|
||||||
|
raise ValueError("já existe pedido pendente para este e-mail")
|
||||||
|
now = _now()
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO desk_registration_requests
|
||||||
|
(email, password_hash, display_name, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'pending', ?, ?)
|
||||||
|
""",
|
||||||
|
(email, auth.hash_password(password), display_name, now, now),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return get_request(conn, int(cur.lastrowid))
|
||||||
|
|
||||||
|
|
||||||
|
def get_request(conn: sqlite3.Connection, request_id: int) -> dict | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM desk_registration_requests WHERE id = ?",
|
||||||
|
(request_id,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_by_token(conn: sqlite3.Connection, token: str) -> dict | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM desk_registration_requests WHERE activation_token = ?",
|
||||||
|
(token,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_requests(conn: sqlite3.Connection, status: str | None = None) -> list[dict]:
|
||||||
|
if status:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM desk_registration_requests WHERE status = ? ORDER BY created_at DESC",
|
||||||
|
(status,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM desk_registration_requests ORDER BY created_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [public_request(dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def factor_status(row: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"email": bool(row.get("email_verified")),
|
||||||
|
"phone": bool(row.get("phone_verified")),
|
||||||
|
"totp": bool(row.get("totp_verified")),
|
||||||
|
"verified_count": sum(
|
||||||
|
1 for k in ("email_verified", "phone_verified", "totp_verified") if row.get(k)
|
||||||
|
),
|
||||||
|
"required": REQUIRED_FACTORS,
|
||||||
|
"ready": sum(1 for k in ("email_verified", "phone_verified", "totp_verified") if row.get(k))
|
||||||
|
>= REQUIRED_FACTORS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def public_request(row: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"email": row["email"],
|
||||||
|
"display_name": row.get("display_name"),
|
||||||
|
"status": row["status"],
|
||||||
|
"role": row.get("role"),
|
||||||
|
"phone": row.get("phone"),
|
||||||
|
"approved_by": row.get("approved_by"),
|
||||||
|
"rejected_by": row.get("rejected_by"),
|
||||||
|
"rejection_reason": row.get("rejection_reason"),
|
||||||
|
"created_at": row.get("created_at"),
|
||||||
|
"updated_at": row.get("updated_at"),
|
||||||
|
"approved_at": row.get("approved_at"),
|
||||||
|
"factors": factor_status(row),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_activation_secrets(conn: sqlite3.Connection, request_id: int) -> dict:
|
||||||
|
row = get_request(conn, request_id)
|
||||||
|
if not row:
|
||||||
|
raise ValueError("request not found")
|
||||||
|
secret = row.get("totp_secret_pending") or generate_secret()
|
||||||
|
topic = row.get("ntfy_topic") or ntfy_topic(row["email"], request_id)
|
||||||
|
if not row.get("totp_secret_pending") or not row.get("ntfy_topic"):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE desk_registration_requests
|
||||||
|
SET totp_secret_pending = ?, ntfy_topic = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(secret, topic, _now(), request_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
row = get_request(conn, request_id)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def approve_request(conn: sqlite3.Connection, request_id: int, role: str, approved_by: str) -> dict:
|
||||||
|
if role not in ROLES or role == "super_admin":
|
||||||
|
raise ValueError("invalid role for new registration")
|
||||||
|
row = get_request(conn, request_id)
|
||||||
|
if not row:
|
||||||
|
raise ValueError("request not found")
|
||||||
|
if row["status"] != "pending":
|
||||||
|
raise ValueError(f"cannot approve status {row['status']}")
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
secret = generate_secret()
|
||||||
|
topic = ntfy_topic(row["email"], request_id)
|
||||||
|
now = _now()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE desk_registration_requests
|
||||||
|
SET status = 'approved', role = ?, activation_token = ?,
|
||||||
|
approved_by = ?, approved_at = ?, updated_at = ?,
|
||||||
|
totp_secret_pending = ?, ntfy_topic = ?,
|
||||||
|
email_verified = 0, phone_verified = 0, totp_verified = 0
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(role, token, approved_by, now, now, secret, topic, request_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return get_request(conn, request_id)
|
||||||
|
|
||||||
|
|
||||||
|
def reject_request(
|
||||||
|
conn: sqlite3.Connection, request_id: int, rejected_by: str, reason: str | None = None
|
||||||
|
) -> dict:
|
||||||
|
row = get_request(conn, request_id)
|
||||||
|
if not row:
|
||||||
|
raise ValueError("request not found")
|
||||||
|
if row["status"] != "pending":
|
||||||
|
raise ValueError(f"cannot reject status {row['status']}")
|
||||||
|
now = _now()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE desk_registration_requests
|
||||||
|
SET status = 'rejected', rejected_by = ?, rejection_reason = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(rejected_by, reason, now, request_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return get_request(conn, request_id)
|
||||||
|
|
||||||
|
|
||||||
|
def set_email_otp(conn: sqlite3.Connection, request_id: int) -> tuple[str, dict]:
|
||||||
|
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE desk_registration_requests
|
||||||
|
SET email_otp = ?, email_otp_expires = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(code, _otp_expires(), _now(), request_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return code, get_request(conn, request_id)
|
||||||
|
|
||||||
|
|
||||||
|
def set_phone_otp(conn: sqlite3.Connection, request_id: int, phone: str) -> tuple[str, dict]:
|
||||||
|
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE desk_registration_requests
|
||||||
|
SET phone = ?, phone_otp = ?, phone_otp_expires = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(phone.strip(), code, _otp_expires(), _now(), request_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return code, get_request(conn, request_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _otp_valid(stored: str | None, expires: str | None, provided: str) -> bool:
|
||||||
|
if not stored or not expires or not provided:
|
||||||
|
return False
|
||||||
|
if stored.strip() != provided.strip():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
exp = datetime.fromisoformat(expires)
|
||||||
|
if exp.tzinfo is None:
|
||||||
|
exp = exp.replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return datetime.now(timezone.utc) <= exp
|
||||||
|
|
||||||
|
|
||||||
|
def _count_verified(row: dict) -> int:
|
||||||
|
return sum(1 for k in ("email_verified", "phone_verified", "totp_verified") if row.get(k))
|
||||||
|
|
||||||
|
|
||||||
|
def complete_activation(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
request_id: int,
|
||||||
|
email_otp: str | None = None,
|
||||||
|
phone_otp: str | None = None,
|
||||||
|
totp_code: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
row = get_request(conn, request_id)
|
||||||
|
if not row:
|
||||||
|
raise ValueError("request not found")
|
||||||
|
if row["status"] != "approved":
|
||||||
|
raise ValueError("request not approved")
|
||||||
|
|
||||||
|
email_verified = bool(row.get("email_verified"))
|
||||||
|
phone_verified = bool(row.get("phone_verified"))
|
||||||
|
totp_verified = bool(row.get("totp_verified"))
|
||||||
|
|
||||||
|
if email_otp and not email_verified:
|
||||||
|
if _otp_valid(row.get("email_otp"), row.get("email_otp_expires"), email_otp):
|
||||||
|
email_verified = True
|
||||||
|
|
||||||
|
if phone_otp and not phone_verified:
|
||||||
|
if row.get("phone") and _otp_valid(row.get("phone_otp"), row.get("phone_otp_expires"), phone_otp):
|
||||||
|
phone_verified = True
|
||||||
|
|
||||||
|
if totp_code and not totp_verified:
|
||||||
|
secret = row.get("totp_secret_pending")
|
||||||
|
if secret and verify_code(secret, totp_code):
|
||||||
|
totp_verified = True
|
||||||
|
|
||||||
|
verified_count = sum([email_verified, phone_verified, totp_verified])
|
||||||
|
if verified_count < REQUIRED_FACTORS:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE desk_registration_requests
|
||||||
|
SET email_verified = ?, phone_verified = ?, totp_verified = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(int(email_verified), int(phone_verified), int(totp_verified), _now(), request_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
raise ValueError(f"valide pelo menos {REQUIRED_FACTORS} fatores ({verified_count}/{REQUIRED_FACTORS})")
|
||||||
|
|
||||||
|
email = row["email"]
|
||||||
|
role = row["role"]
|
||||||
|
if not role:
|
||||||
|
raise ValueError("role not set")
|
||||||
|
now = _now()
|
||||||
|
display = row.get("display_name") or email.split("@")[0]
|
||||||
|
totp_secret = row.get("totp_secret_pending") if totp_verified else None
|
||||||
|
totp_enabled = 1 if totp_verified else 0
|
||||||
|
phone = row.get("phone") if phone_verified else None
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO desk_users
|
||||||
|
(username, password_hash, role, display_name, email, phone,
|
||||||
|
mfa_enabled, totp_secret, totp_enabled, active, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, 1, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
email,
|
||||||
|
row["password_hash"],
|
||||||
|
role,
|
||||||
|
display,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
totp_secret,
|
||||||
|
totp_enabled,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE desk_registration_requests
|
||||||
|
SET status = 'active', email_verified = ?, phone_verified = ?, totp_verified = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(int(email_verified), int(phone_verified), int(totp_verified), now, request_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
result = get_request(conn, request_id)
|
||||||
|
if totp_enabled and totp_secret:
|
||||||
|
codes = backup_codes.generate_backup_codes()
|
||||||
|
backup_codes.store_backup_codes(conn, email, codes)
|
||||||
|
conn.commit()
|
||||||
|
result = dict(result)
|
||||||
|
result["backup_codes"] = codes
|
||||||
|
return result
|
||||||
136
ligbox-ops-platform/api/app/security_routes.py
Normal file
136
ligbox-ops-platform/api/app/security_routes.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""Rotas segurança wizard — Spec 021."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app import auth, security_store
|
||||||
|
from app.permissions import can_read_audit_overview
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/security", tags=["wizard-security"])
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityWebhookBody(BaseModel):
|
||||||
|
event: str = Field(..., min_length=3)
|
||||||
|
domain: str | None = None
|
||||||
|
session_id: str | None = None
|
||||||
|
data: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityAuditTestBody(BaseModel):
|
||||||
|
field: str = "domain"
|
||||||
|
value: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_security_reader(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
|
||||||
|
if not can_read_audit_overview(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str | 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 _module_enabled() -> bool:
|
||||||
|
from app.modules import store as module_store
|
||||||
|
|
||||||
|
return module_store.is_module_enabled("wizard-security")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/csp-report")
|
||||||
|
async def csp_report(request: Request):
|
||||||
|
if not _module_enabled():
|
||||||
|
return {"accepted": False, "reason": "module_disabled"}
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
return security_store.ingest_csp_report(conn, body if isinstance(body, dict) else {}, _client_ip(request))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhook")
|
||||||
|
def security_webhook(
|
||||||
|
body: SecurityWebhookBody,
|
||||||
|
request: Request,
|
||||||
|
x_webhook_secret: str | None = Header(default=None),
|
||||||
|
):
|
||||||
|
from app.main import INTEGRATION_SECRETS, _verify_secret
|
||||||
|
|
||||||
|
if not _module_enabled():
|
||||||
|
return {"accepted": False, "reason": "module_disabled"}
|
||||||
|
_verify_secret("onboard", x_webhook_secret)
|
||||||
|
if not security_store.is_security_event(body.event):
|
||||||
|
raise HTTPException(400, "event must start with security.")
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
return security_store.ingest_event(
|
||||||
|
conn,
|
||||||
|
event=body.event,
|
||||||
|
session_id=body.session_id,
|
||||||
|
domain=body.domain,
|
||||||
|
data=body.data,
|
||||||
|
client_ip=_client_ip(request),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
def security_summary(
|
||||||
|
window_hours: int = Query(24, ge=1, le=168),
|
||||||
|
user: auth.DeskUser = Depends(_require_security_reader),
|
||||||
|
):
|
||||||
|
if not _module_enabled():
|
||||||
|
return {"enabled": False, "window_hours": window_hours, "total": 0}
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
return security_store.build_summary(conn, window_hours=window_hours)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
def security_events(
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
window_hours: int = Query(168, ge=1, le=720),
|
||||||
|
session_id: str = "",
|
||||||
|
user: auth.DeskUser = Depends(_require_security_reader),
|
||||||
|
):
|
||||||
|
if not _module_enabled():
|
||||||
|
return {"events": [], "total": 0, "enabled": False}
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
return security_store.list_events(
|
||||||
|
conn,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
window_hours=window_hours,
|
||||||
|
session_id=session_id.strip() or None,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/audit-test")
|
||||||
|
def security_audit_test(
|
||||||
|
body: SecurityAuditTestBody,
|
||||||
|
user: auth.DeskUser = Depends(_require_security_reader),
|
||||||
|
):
|
||||||
|
"""Teste interno — simula heurística de input (sem gravar)."""
|
||||||
|
if user.role not in ("super_admin", "ops_lead"):
|
||||||
|
raise HTTPException(403, "apenas admin")
|
||||||
|
return security_store.audit_field_value(body.value, field=body.field)
|
||||||
289
ligbox-ops-platform/api/app/security_store.py
Normal file
289
ligbox-ops-platform/api/app/security_store.py
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
"""Segurança wizard VM112 — telemetria Spec 021."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
SECURITY_SOURCE = "vm112-security"
|
||||||
|
SECURITY_PREFIX = "security."
|
||||||
|
VM112_TENANT_ID = 1
|
||||||
|
|
||||||
|
AUTO_TICKET_EVENTS = frozenset({
|
||||||
|
"security.input_blocked",
|
||||||
|
"security.handoff_rejected",
|
||||||
|
"security.session_anomaly",
|
||||||
|
})
|
||||||
|
|
||||||
|
SEVERITY_BY_EVENT = {
|
||||||
|
"security.csp_violation": "warn",
|
||||||
|
"security.input_warn": "info",
|
||||||
|
"security.input_blocked": "high",
|
||||||
|
"security.rate_limited": "warn",
|
||||||
|
"security.handoff_created": "info",
|
||||||
|
"security.handoff_consumed": "info",
|
||||||
|
"security.handoff_rejected": "high",
|
||||||
|
"security.handoff_expired": "info",
|
||||||
|
"security.auth_failed": "warn",
|
||||||
|
"security.session_anomaly": "high",
|
||||||
|
}
|
||||||
|
|
||||||
|
FORBIDDEN_PAYLOAD_KEYS = frozenset({
|
||||||
|
"password",
|
||||||
|
"root_password",
|
||||||
|
"new_password",
|
||||||
|
"current_password",
|
||||||
|
"handoff_token",
|
||||||
|
"token",
|
||||||
|
"secret",
|
||||||
|
})
|
||||||
|
|
||||||
|
SQLI_PATTERNS = [
|
||||||
|
re.compile(r"'\s*or\s+", re.I),
|
||||||
|
re.compile(r"union\s+select", re.I),
|
||||||
|
re.compile(r";\s*drop\s+", re.I),
|
||||||
|
re.compile(r"1\s*=\s*1", re.I),
|
||||||
|
re.compile(r"--\s*$"),
|
||||||
|
]
|
||||||
|
|
||||||
|
XSS_PATTERNS = [
|
||||||
|
re.compile(r"<\s*script", re.I),
|
||||||
|
re.compile(r"javascript\s*:", re.I),
|
||||||
|
re.compile(r"onerror\s*=", re.I),
|
||||||
|
re.compile(r"onload\s*=", re.I),
|
||||||
|
]
|
||||||
|
|
||||||
|
PATH_PATTERNS = [
|
||||||
|
re.compile(r"\.\./"),
|
||||||
|
re.compile(r"%2e%2e", re.I),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_payload(raw: str | None) -> dict:
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_data(data: dict | None) -> dict:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for key, val in data.items():
|
||||||
|
if key.lower() in FORBIDDEN_PAYLOAD_KEYS:
|
||||||
|
continue
|
||||||
|
if isinstance(val, str) and len(val) > 500:
|
||||||
|
out[key] = val[:500] + "…"
|
||||||
|
else:
|
||||||
|
out[key] = val
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def is_security_event(event: str) -> bool:
|
||||||
|
return bool(event) and event.startswith(SECURITY_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
def audit_field_value(value: str, *, field: str = "") -> dict[str, Any]:
|
||||||
|
"""Heurística local (VM122) — espelho do middleware VM112."""
|
||||||
|
text = (value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {"ok": True}
|
||||||
|
if len(text) > 2000:
|
||||||
|
return {"ok": False, "reason": "oversize", "pattern_id": "field_too_long", "severity": "high"}
|
||||||
|
for pat in SQLI_PATTERNS:
|
||||||
|
if pat.search(text):
|
||||||
|
return {"ok": False, "reason": "sql_injection_pattern", "pattern_id": pat.pattern[:40], "severity": "high"}
|
||||||
|
for pat in XSS_PATTERNS:
|
||||||
|
if pat.search(text):
|
||||||
|
return {"ok": False, "reason": "xss_pattern", "pattern_id": pat.pattern[:40], "severity": "high"}
|
||||||
|
for pat in PATH_PATTERNS:
|
||||||
|
if pat.search(text):
|
||||||
|
return {"ok": False, "reason": "path_traversal", "pattern_id": pat.pattern[:40], "severity": "high"}
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_row(row) -> dict[str, Any]:
|
||||||
|
payload = _parse_payload(row["payload"])
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"event_type": row["event_type"],
|
||||||
|
"source": row["source"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"session_id": payload.get("session_id"),
|
||||||
|
"domain": payload.get("domain"),
|
||||||
|
"severity": data.get("severity") or SEVERITY_BY_EVENT.get(row["event_type"], "info"),
|
||||||
|
"client_ip": data.get("client_ip") or payload.get("ingress_client_ip"),
|
||||||
|
"endpoint": data.get("endpoint"),
|
||||||
|
"reason": data.get("reason"),
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_event(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
event: str,
|
||||||
|
session_id: str | None = None,
|
||||||
|
domain: str | None = None,
|
||||||
|
data: dict | None = None,
|
||||||
|
client_ip: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not is_security_event(event):
|
||||||
|
raise ValueError(f"not a security event: {event}")
|
||||||
|
now = _now()
|
||||||
|
clean_data = _scrub_data(data)
|
||||||
|
if client_ip and not clean_data.get("client_ip"):
|
||||||
|
clean_data["client_ip"] = client_ip
|
||||||
|
if "severity" not in clean_data:
|
||||||
|
clean_data["severity"] = SEVERITY_BY_EVENT.get(event, "info")
|
||||||
|
stored = {
|
||||||
|
"event": event,
|
||||||
|
"source": SECURITY_SOURCE,
|
||||||
|
"session_id": session_id,
|
||||||
|
"domain": domain,
|
||||||
|
"data": clean_data,
|
||||||
|
}
|
||||||
|
if client_ip:
|
||||||
|
stored["ingress_client_ip"] = client_ip
|
||||||
|
payload = json.dumps(stored, ensure_ascii=False)
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO webhook_events (event_type, source, payload, created_at) VALUES (?,?,?,?)",
|
||||||
|
(event, SECURITY_SOURCE, payload, now),
|
||||||
|
)
|
||||||
|
event_id = int(cur.lastrowid)
|
||||||
|
ticket_id = None
|
||||||
|
if event in AUTO_TICKET_EVENTS:
|
||||||
|
domain_label = domain or "sem domínio"
|
||||||
|
subject = f"[security] {domain_label} — {event.replace('security.', '')}"
|
||||||
|
cur2 = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tickets (tenant_id, subject, status, payload, created_at, session_id)
|
||||||
|
VALUES (?, ?, 'escalated', ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(VM112_TENANT_ID, subject, payload, now, session_id),
|
||||||
|
)
|
||||||
|
ticket_id = int(cur2.lastrowid)
|
||||||
|
conn.commit()
|
||||||
|
return {
|
||||||
|
"accepted": True,
|
||||||
|
"event_id": event_id,
|
||||||
|
"event": event,
|
||||||
|
"ticket_id": ticket_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_csp_report(conn, body: dict, client_ip: str | None = None) -> dict[str, Any]:
|
||||||
|
report = body.get("csp-report") or body.get("csp_report") or body
|
||||||
|
if not isinstance(report, dict):
|
||||||
|
report = {}
|
||||||
|
data = {
|
||||||
|
"document_uri": report.get("document-uri") or report.get("document_uri"),
|
||||||
|
"violated_directive": report.get("violated-directive") or report.get("violated_directive"),
|
||||||
|
"blocked_uri": report.get("blocked-uri") or report.get("blocked_uri"),
|
||||||
|
"source_file": report.get("source-file") or report.get("source_file"),
|
||||||
|
"line_number": report.get("line-number") or report.get("line_number"),
|
||||||
|
"severity": "warn",
|
||||||
|
"client_ip": client_ip,
|
||||||
|
}
|
||||||
|
return ingest_event(
|
||||||
|
conn,
|
||||||
|
event="security.csp_violation",
|
||||||
|
data=data,
|
||||||
|
client_ip=client_ip,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_summary(conn, *, window_hours: int = 24) -> dict[str, Any]:
|
||||||
|
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 DESC
|
||||||
|
""",
|
||||||
|
(SECURITY_SOURCE, cutoff),
|
||||||
|
).fetchall()
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
sessions: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
counts[row["event_type"]] = counts.get(row["event_type"], 0) + 1
|
||||||
|
p = _parse_payload(row["payload"])
|
||||||
|
sid = (p.get("session_id") or "").strip()
|
||||||
|
if sid:
|
||||||
|
sessions.add(sid)
|
||||||
|
recent = list_events(conn, limit=8, offset=0, window_hours=window_hours)["events"]
|
||||||
|
return {
|
||||||
|
"window_hours": window_hours,
|
||||||
|
"total": len(rows),
|
||||||
|
"csp_violations": counts.get("security.csp_violation", 0),
|
||||||
|
"inputs_blocked": counts.get("security.input_blocked", 0),
|
||||||
|
"inputs_warn": counts.get("security.input_warn", 0),
|
||||||
|
"handoffs_rejected": counts.get("security.handoff_rejected", 0),
|
||||||
|
"rate_limited": counts.get("security.rate_limited", 0),
|
||||||
|
"sessions_with_alerts": len(sessions),
|
||||||
|
"by_event": counts,
|
||||||
|
"recent": recent,
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_events(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
window_hours: int = 168,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
limit = max(1, min(int(limit), 500))
|
||||||
|
offset = max(0, int(offset))
|
||||||
|
cutoff = (datetime.now(timezone.utc) - timedelta(hours=window_hours)).isoformat()
|
||||||
|
if session_id:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, event_type, source, payload, created_at
|
||||||
|
FROM webhook_events
|
||||||
|
WHERE source = ? AND created_at >= ? AND payload LIKE ?
|
||||||
|
ORDER BY id DESC LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(SECURITY_SOURCE, cutoff, f'%"{session_id}"%', limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
total = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM webhook_events
|
||||||
|
WHERE source = ? AND created_at >= ? AND payload LIKE ?
|
||||||
|
""",
|
||||||
|
(SECURITY_SOURCE, cutoff, f'%"{session_id}"%',),
|
||||||
|
).fetchone()[0]
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, event_type, source, payload, created_at
|
||||||
|
FROM webhook_events
|
||||||
|
WHERE source = ? AND created_at >= ?
|
||||||
|
ORDER BY id DESC LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(SECURITY_SOURCE, cutoff, limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
total = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM webhook_events WHERE source = ? AND created_at >= ?",
|
||||||
|
(SECURITY_SOURCE, cutoff),
|
||||||
|
).fetchone()[0]
|
||||||
|
return {
|
||||||
|
"events": [_enrich_row(r) for r in rows],
|
||||||
|
"total": int(total),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"window_hours": window_hours,
|
||||||
|
}
|
||||||
30
ligbox-ops-platform/api/app/totp_util.py
Normal file
30
ligbox-ops-platform/api/app/totp_util.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""TOTP helpers for Desk 2FA."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
|
||||||
|
|
||||||
|
def generate_secret() -> str:
|
||||||
|
return pyotp.random_base32()
|
||||||
|
|
||||||
|
|
||||||
|
def otpauth_uri(email: str, secret: str, issuer: str = "Ligbox Ops") -> str:
|
||||||
|
return pyotp.totp.TOTP(secret).provisioning_uri(name=email, issuer_name=issuer)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_code(secret: str, code: str) -> bool:
|
||||||
|
if not secret or not code:
|
||||||
|
return False
|
||||||
|
clean = code.strip().replace(" ", "")
|
||||||
|
if len(clean) != 6 or not clean.isdigit():
|
||||||
|
return False
|
||||||
|
return pyotp.TOTP(secret).verify(clean, valid_window=1)
|
||||||
|
|
||||||
|
|
||||||
|
def ntfy_topic(email: str, request_id: int) -> str:
|
||||||
|
digest = hashlib.sha256(f"{email}:{request_id}".encode()).hexdigest()[:14]
|
||||||
|
return f"ligbox-{digest}"
|
||||||
297
ligbox-ops-platform/api/app/vm112_domains.py
Normal file
297
ligbox-ops-platform/api/app/vm112_domains.py
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
"""Proxy VM112 domínios orquestrados + limpeza Desk (Spec 017)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app import auth
|
||||||
|
|
||||||
|
VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090")
|
||||||
|
VM112_ADMIN_API_KEY = os.getenv("VM112_ADMIN_API_KEY", "ibytera-corp-api-key-change-later")
|
||||||
|
|
||||||
|
PURGE_BLOCKLIST = frozenset({"ligbox.com.br", "itecnologys.com"})
|
||||||
|
|
||||||
|
VM112_PURGE_STEP_LABELS = (
|
||||||
|
"Contas Carbonio (zmprov da)",
|
||||||
|
"Domínio Carbonio (zmprov dd)",
|
||||||
|
"Portal users Self-Service",
|
||||||
|
"Pasta ligbox-sites",
|
||||||
|
"Zona Cloudflare Ibytera",
|
||||||
|
"Traefik / SNI CT114",
|
||||||
|
"Logs de sessão wizard",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ts() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _timeline_entry(label: str, status: str, detail: str = "") -> dict[str, str]:
|
||||||
|
return {"at": _ts(), "label": label, "status": status, "detail": detail}
|
||||||
|
|
||||||
|
|
||||||
|
def _vm112_headers() -> dict[str, str]:
|
||||||
|
return {"X-Api-Key": VM112_ADMIN_API_KEY}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_root_password(conn: sqlite3.Connection, password: str) -> bool:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT password_hash FROM desk_users WHERE username = 'root' AND active = 1"
|
||||||
|
).fetchone()
|
||||||
|
if not row or not row["password_hash"]:
|
||||||
|
return False
|
||||||
|
return auth.verify_password(password, row["password_hash"])
|
||||||
|
|
||||||
|
|
||||||
|
def delete_carbonio_account(email: str) -> dict[str, Any]:
|
||||||
|
"""Remove uma conta Carbonio (zmprov da) — Spec 022."""
|
||||||
|
email = email.lower().strip()
|
||||||
|
if "@" not in email:
|
||||||
|
raise ValueError("e-mail inválido")
|
||||||
|
domain = email.split("@", 1)[1]
|
||||||
|
if domain in PURGE_BLOCKLIST:
|
||||||
|
raise ValueError(f"Domínio protegido: {domain}")
|
||||||
|
with httpx.Client(timeout=120.0) as client:
|
||||||
|
r = client.post(
|
||||||
|
f"{VM112_API}/api/admin/accounts/{email}/delete",
|
||||||
|
headers=_vm112_headers(),
|
||||||
|
)
|
||||||
|
if r.status_code == 404:
|
||||||
|
return {"ok": True, "email": email, "message": "Conta já não existia no Carbonio", "skipped": True}
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"email": email,
|
||||||
|
"message": data.get("message") or f"Conta {email} removida",
|
||||||
|
"detail": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_domains(query: str = "") -> dict[str, Any]:
|
||||||
|
with httpx.Client(timeout=60.0) as client:
|
||||||
|
r = client.get(
|
||||||
|
f"{VM112_API}/api/admin/domains",
|
||||||
|
params={"q": query} if query else None,
|
||||||
|
headers=_vm112_headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_domain(domain: str) -> dict[str, Any]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
with httpx.Client(timeout=180.0) as client:
|
||||||
|
r = client.get(
|
||||||
|
f"{VM112_API}/api/admin/domains/{domain}",
|
||||||
|
headers=_vm112_headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def domain_exists_on_vm112(domain: str) -> bool:
|
||||||
|
"""True se o domínio ainda consta na lista orquestrada VM112."""
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
try:
|
||||||
|
data = list_domains()
|
||||||
|
items = data.get("domains") if isinstance(data, dict) else data
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return True
|
||||||
|
for item in items:
|
||||||
|
name = item.get("domain") if isinstance(item, dict) else item
|
||||||
|
if str(name or "").lower().strip() == domain:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
# VM112 indisponível — não assumir removido durante poll
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def start_purge_vm112(domain: str) -> dict[str, Any]:
|
||||||
|
"""Inicia purge assíncrono na VM112 (Spec 017 Fase 3)."""
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
with httpx.Client(timeout=60.0) as client:
|
||||||
|
r = client.post(
|
||||||
|
f"{VM112_API}/api/admin/domains/{domain}/purge",
|
||||||
|
headers=_vm112_headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def poll_purge_vm112_job(job_id: str) -> dict[str, Any]:
|
||||||
|
with httpx.Client(timeout=60.0) as client:
|
||||||
|
r = client.get(
|
||||||
|
f"{VM112_API}/api/admin/domains/purge-jobs/{job_id}",
|
||||||
|
headers=_vm112_headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def vm112_job_steps_timeline(job: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
"""Passos individuais VM112 durante execução (Fase 3)."""
|
||||||
|
out: list[dict[str, str]] = []
|
||||||
|
for step in job.get("steps") or []:
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
st = str(step.get("status") or "pending")
|
||||||
|
if st == "pending":
|
||||||
|
continue
|
||||||
|
label = str(step.get("label") or "Passo VM112")
|
||||||
|
if st == "done":
|
||||||
|
status = "ok"
|
||||||
|
elif st == "error":
|
||||||
|
status = "fail"
|
||||||
|
else:
|
||||||
|
status = "running"
|
||||||
|
detail = str(step.get("detail") or "")
|
||||||
|
at = step.get("finished_at") or step.get("started_at") or _ts()
|
||||||
|
out.append({"at": at, "label": label, "status": status, "detail": detail})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def purge_vm112_with_poll(domain: str, poll_interval: float = 1.5, timeout: float = 600.0):
|
||||||
|
"""Generator: (event_type, payload) — passos em tempo real + resultado final."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
started = start_purge_vm112(domain)
|
||||||
|
job_id = started.get("job_id")
|
||||||
|
if not job_id:
|
||||||
|
yield ("final", started)
|
||||||
|
return
|
||||||
|
|
||||||
|
t0 = time.monotonic()
|
||||||
|
deadline = t0 + timeout
|
||||||
|
seen = 0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
job = poll_purge_vm112_job(job_id)
|
||||||
|
steps = vm112_job_steps_timeline(job)
|
||||||
|
if len(steps) > seen:
|
||||||
|
for step in steps[seen:]:
|
||||||
|
yield ("step", step)
|
||||||
|
seen = len(steps)
|
||||||
|
status = job.get("status")
|
||||||
|
if status == "completed":
|
||||||
|
yield (
|
||||||
|
"final",
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"job_id": job_id,
|
||||||
|
"steps": steps,
|
||||||
|
"result": job.get("result") or {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if status == "failed":
|
||||||
|
yield (
|
||||||
|
"final",
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"job_id": job_id,
|
||||||
|
"steps": steps,
|
||||||
|
"error": job.get("error") or "Purge VM112 falhou",
|
||||||
|
"result": job.get("result") or {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
yield ("heartbeat", {"elapsed": int(time.monotonic() - t0), "job_id": job_id})
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
yield ("final", {"ok": False, "error": "Timeout purge VM112", "job_id": job_id})
|
||||||
|
|
||||||
|
|
||||||
|
def purge_vm112(domain: str) -> dict[str, Any]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
for kind, payload in purge_vm112_with_poll(domain):
|
||||||
|
if kind == "final":
|
||||||
|
return payload
|
||||||
|
return {"ok": False, "error": "Purge VM112 sem resposta"}
|
||||||
|
|
||||||
|
|
||||||
|
def vm112_purge_timeline(vm112_result: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
"""Converte resposta VM112 em linhas de timeline."""
|
||||||
|
raw_steps = vm112_result.get("steps")
|
||||||
|
if isinstance(raw_steps, list) and raw_steps:
|
||||||
|
out: list[dict[str, str]] = []
|
||||||
|
for step in raw_steps:
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
label = str(step.get("label") or step.get("name") or "Passo VM112")
|
||||||
|
ok = step.get("ok", step.get("success", True))
|
||||||
|
status = "ok" if ok else "fail"
|
||||||
|
detail = str(step.get("message") or step.get("detail") or "")
|
||||||
|
at = step.get("at") or _ts()
|
||||||
|
out.append({"at": at, "label": label, "status": status, "detail": detail})
|
||||||
|
return out
|
||||||
|
if vm112_result.get("ok") is False:
|
||||||
|
return [
|
||||||
|
_timeline_entry(
|
||||||
|
"Purge VM112",
|
||||||
|
"fail",
|
||||||
|
str(vm112_result.get("message") or vm112_result.get("error") or "falhou"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return [_timeline_entry("Purge VM112", "ok", "Orquestração VM112 concluída")]
|
||||||
|
|
||||||
|
|
||||||
|
def purge_desk_records(conn: sqlite3.Connection, domain: str) -> dict[str, int]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
like = f"%{domain}%"
|
||||||
|
counts = {}
|
||||||
|
counts["webhook_events"] = conn.execute(
|
||||||
|
"DELETE FROM webhook_events WHERE payload LIKE ?", (like,)
|
||||||
|
).rowcount
|
||||||
|
counts["tickets"] = conn.execute(
|
||||||
|
"DELETE FROM tickets WHERE subject LIKE ? OR payload LIKE ?", (like, like)
|
||||||
|
).rowcount
|
||||||
|
counts["audit_domains"] = conn.execute(
|
||||||
|
"DELETE FROM audit_domains WHERE domain = ?", (domain,)
|
||||||
|
).rowcount
|
||||||
|
counts["assist_sessions"] = conn.execute(
|
||||||
|
"DELETE FROM assist_sessions WHERE domain = ?", (domain,)
|
||||||
|
).rowcount
|
||||||
|
counts["audit_checks"] = conn.execute(
|
||||||
|
"DELETE FROM audit_checks WHERE domain = ?", (domain,)
|
||||||
|
).rowcount
|
||||||
|
conn.commit()
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def purge_desk_timeline(conn: sqlite3.Connection, domain: str) -> tuple[dict[str, int], list[dict[str, str]]]:
|
||||||
|
"""Purge Desk com uma linha de timeline por tabela."""
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
like = f"%{domain}%"
|
||||||
|
timeline: list[dict[str, str]] = []
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
|
||||||
|
desk_steps = (
|
||||||
|
("Desk — webhook_events", "webhook_events", "DELETE FROM webhook_events WHERE payload LIKE ?", (like,)),
|
||||||
|
("Desk — tickets", "tickets", "DELETE FROM tickets WHERE subject LIKE ? OR payload LIKE ?", (like, like)),
|
||||||
|
("Desk — audit_domains", "audit_domains", "DELETE FROM audit_domains WHERE domain = ?", (domain,)),
|
||||||
|
("Desk — assist_sessions", "assist_sessions", "DELETE FROM assist_sessions WHERE domain = ?", (domain,)),
|
||||||
|
("Desk — audit_checks", "audit_checks", "DELETE FROM audit_checks WHERE domain = ?", (domain,)),
|
||||||
|
)
|
||||||
|
for label, key, sql, params in desk_steps:
|
||||||
|
n = conn.execute(sql, params).rowcount
|
||||||
|
counts[key] = n
|
||||||
|
timeline.append(_timeline_entry(label, "ok", f"{n} registo(s) removido(s)"))
|
||||||
|
conn.commit()
|
||||||
|
return counts, timeline
|
||||||
|
|
||||||
|
|
||||||
|
def build_purge_timeline(vm112_result: dict[str, Any], desk_counts: dict[str, int], desk_timeline: list[dict[str, str]]) -> list[dict[str, str]]:
|
||||||
|
timeline = [_timeline_entry("Validação Root + confirmação", "ok")]
|
||||||
|
timeline.extend(vm112_purge_timeline(vm112_result))
|
||||||
|
timeline.extend(desk_timeline)
|
||||||
|
total_desk = sum(desk_counts.values())
|
||||||
|
timeline.append(_timeline_entry("Purge concluído", "ok", f"Desk: {total_desk} registo(s)"))
|
||||||
|
return timeline
|
||||||
153
ligbox-ops-platform/api/app/vm112_domains_routes.py
Normal file
153
ligbox-ops-platform/api/app/vm112_domains_routes.py
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
"""Rotas Desk — domínios VM112 (Spec 017)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app import auth, vm112_domains
|
||||||
|
from app.permissions import can_manage_vm112_domains
|
||||||
|
from app.vm112_purge_stream import purge_sse_generator
|
||||||
|
from app.vm112_purge_jobs import get_job_public, list_jobs, recover_job, start_job
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/vm112", tags=["vm112-domains"])
|
||||||
|
|
||||||
|
|
||||||
|
class DomainPurgeRequest(BaseModel):
|
||||||
|
confirm_domain: str = Field(..., min_length=3)
|
||||||
|
root_password: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
|
||||||
|
if not can_manage_vm112_domains(user.role):
|
||||||
|
raise HTTPException(403, "Apenas perfis Admin (super_admin, ops_lead)")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_purge_request(domain: str, body: DomainPurgeRequest) -> str:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
if domain in vm112_domains.PURGE_BLOCKLIST:
|
||||||
|
raise HTTPException(400, f"Domínio {domain} está protegido contra purge")
|
||||||
|
if body.confirm_domain.lower().strip() != domain:
|
||||||
|
raise HTTPException(400, "Confirmação do domínio não coincide")
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/domains")
|
||||||
|
def list_vm112_domains(
|
||||||
|
q: str = "",
|
||||||
|
user: auth.DeskUser = Depends(_require_admin),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return vm112_domains.list_domains(q)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"VM112 indisponível: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/domains/{domain}")
|
||||||
|
def get_vm112_domain(
|
||||||
|
domain: str,
|
||||||
|
user: auth.DeskUser = Depends(_require_admin),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return vm112_domains.get_domain(domain)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"VM112: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/domains/{domain}/purge")
|
||||||
|
def purge_vm112_domain(
|
||||||
|
domain: str,
|
||||||
|
body: DomainPurgeRequest,
|
||||||
|
user: auth.DeskUser = Depends(_require_admin),
|
||||||
|
):
|
||||||
|
domain = _validate_purge_request(domain, body)
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
if not vm112_domains.verify_root_password(conn, body.root_password):
|
||||||
|
raise HTTPException(403, "Senha Root incorrecta")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
try:
|
||||||
|
vm112_result = vm112_domains.purge_vm112(domain)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"Purge VM112 falhou: {e}") from e
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
desk_counts, desk_timeline = vm112_domains.purge_desk_timeline(conn, domain)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
timeline = vm112_domains.build_purge_timeline(vm112_result, desk_counts, desk_timeline)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"domain": domain,
|
||||||
|
"vm112": vm112_result,
|
||||||
|
"desk": desk_counts,
|
||||||
|
"timeline": timeline,
|
||||||
|
"by": user.username,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/domains/{domain}/purge/stream")
|
||||||
|
def purge_vm112_domain_stream(
|
||||||
|
domain: str,
|
||||||
|
body: DomainPurgeRequest,
|
||||||
|
user: auth.DeskUser = Depends(_require_admin),
|
||||||
|
):
|
||||||
|
"""SSE — progresso purge em tempo real (Fase 2 Spec 017)."""
|
||||||
|
domain = _validate_purge_request(domain, body)
|
||||||
|
return StreamingResponse(
|
||||||
|
purge_sse_generator(domain, body.root_password, user.username),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/domains/{domain}/purge/jobs")
|
||||||
|
def start_purge_job(
|
||||||
|
domain: str,
|
||||||
|
body: DomainPurgeRequest,
|
||||||
|
user: auth.DeskUser = Depends(_require_admin),
|
||||||
|
):
|
||||||
|
"""Inicia purge em background; consultar GET /purge/jobs/{id} (recomendado via Traefik)."""
|
||||||
|
domain = _validate_purge_request(domain, body)
|
||||||
|
job_id = start_job(domain, body.root_password, user.username)
|
||||||
|
return {"ok": True, "job_id": job_id, "domain": domain, "status": "running"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/purge/jobs")
|
||||||
|
def list_purge_jobs(
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
user: auth.DeskUser = Depends(_require_admin),
|
||||||
|
):
|
||||||
|
return list_jobs(limit=limit, offset=offset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/purge/jobs/{job_id}")
|
||||||
|
def get_purge_job_status(
|
||||||
|
job_id: str,
|
||||||
|
user: auth.DeskUser = Depends(_require_admin),
|
||||||
|
):
|
||||||
|
job = get_job_public(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "Job purge não encontrado")
|
||||||
|
return job
|
||||||
|
|
||||||
|
@router.post("/purge/jobs/{job_id}/recover")
|
||||||
|
def recover_purge_job(
|
||||||
|
job_id: str,
|
||||||
|
domain: str = "",
|
||||||
|
user: auth.DeskUser = Depends(_require_admin),
|
||||||
|
):
|
||||||
|
"""Recupera purge quando job sumiu da memória mas VM112 já removeu o domínio."""
|
||||||
|
job = recover_job(job_id, domain or None)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "Não foi possível recuperar o job purge")
|
||||||
|
return job
|
||||||
|
|
||||||
385
ligbox-ops-platform/api/app/vm112_purge_jobs.py
Normal file
385
ligbox-ops-platform/api/app/vm112_purge_jobs.py
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
"""Purge assíncrono com polling + persistência SQLite (Spec 017 Fase 2b/3)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from app import auth, vm112_domains
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def init_purge_jobs_schema(conn) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS vm112_purge_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
timeline_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
elapsed_vm112 INTEGER NOT NULL DEFAULT 0,
|
||||||
|
desk_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
vm112_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
error TEXT,
|
||||||
|
by_user TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_schema() -> None:
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
init_purge_jobs_schema(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_job(row) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"job_id": row["id"],
|
||||||
|
"domain": row["domain"],
|
||||||
|
"status": row["status"],
|
||||||
|
"timeline": json.loads(row["timeline_json"] or "[]"),
|
||||||
|
"elapsed_vm112": int(row["elapsed_vm112"] or 0),
|
||||||
|
"desk": json.loads(row["desk_json"] or "{}"),
|
||||||
|
"vm112": json.loads(row["vm112_json"] or "{}"),
|
||||||
|
"error": row["error"],
|
||||||
|
"by": row["by_user"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_job(job: dict[str, Any]) -> None:
|
||||||
|
_ensure_schema()
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
job["updated_at"] = _now()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO vm112_purge_jobs (
|
||||||
|
id, domain, status, timeline_json, elapsed_vm112,
|
||||||
|
desk_json, vm112_json, error, by_user, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
status = excluded.status,
|
||||||
|
timeline_json = excluded.timeline_json,
|
||||||
|
elapsed_vm112 = excluded.elapsed_vm112,
|
||||||
|
desk_json = excluded.desk_json,
|
||||||
|
vm112_json = excluded.vm112_json,
|
||||||
|
error = excluded.error,
|
||||||
|
by_user = excluded.by_user,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
job["id"],
|
||||||
|
job["domain"],
|
||||||
|
job["status"],
|
||||||
|
json.dumps(job.get("timeline") or [], ensure_ascii=False),
|
||||||
|
int(job.get("elapsed_vm112") or 0),
|
||||||
|
json.dumps(job.get("desk") or {}, ensure_ascii=False),
|
||||||
|
json.dumps(job.get("vm112") or {}, ensure_ascii=False),
|
||||||
|
job.get("error"),
|
||||||
|
job.get("by"),
|
||||||
|
job.get("created_at") or _now(),
|
||||||
|
job["updated_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_job(job_id: str) -> dict[str, Any] | None:
|
||||||
|
_ensure_schema()
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM vm112_purge_jobs WHERE id = ?", (job_id,)
|
||||||
|
).fetchone()
|
||||||
|
return _row_to_job(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _mutate_job(job_id: str, fn: Callable[[dict[str, Any]], None]) -> dict[str, Any] | None:
|
||||||
|
with _lock:
|
||||||
|
job = _load_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
fn(job)
|
||||||
|
_persist_job(job)
|
||||||
|
return dict(job)
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_step(job_id: str, step: dict[str, str]) -> None:
|
||||||
|
def _apply(job: dict[str, Any]) -> None:
|
||||||
|
timeline: list[dict[str, str]] = job.setdefault("timeline", [])
|
||||||
|
for i, existing in enumerate(timeline):
|
||||||
|
if existing.get("label") == step.get("label"):
|
||||||
|
timeline[i] = step
|
||||||
|
return
|
||||||
|
timeline.append(step)
|
||||||
|
|
||||||
|
_mutate_job(job_id, _apply)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_job(job_id: str, **fields: Any) -> None:
|
||||||
|
_mutate_job(job_id, lambda job: job.update(fields))
|
||||||
|
|
||||||
|
|
||||||
|
def create_job(domain: str, username: str) -> str:
|
||||||
|
job_id = uuid.uuid4().hex[:16]
|
||||||
|
now = _now()
|
||||||
|
job = {
|
||||||
|
"id": job_id,
|
||||||
|
"job_id": job_id,
|
||||||
|
"domain": domain.lower().strip(),
|
||||||
|
"status": "queued",
|
||||||
|
"timeline": [],
|
||||||
|
"elapsed_vm112": 0,
|
||||||
|
"desk": {},
|
||||||
|
"vm112": {},
|
||||||
|
"error": None,
|
||||||
|
"by": username,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
with _lock:
|
||||||
|
_persist_job(job)
|
||||||
|
return job_id
|
||||||
|
|
||||||
|
|
||||||
|
def start_job(domain: str, root_password: str, username: str) -> str:
|
||||||
|
job_id = create_job(domain, username)
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_execute_job,
|
||||||
|
args=(job_id, domain, root_password, username),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
return job_id
|
||||||
|
|
||||||
|
|
||||||
|
def _desk_already_done(job: dict[str, Any]) -> bool:
|
||||||
|
for step in job.get("timeline") or []:
|
||||||
|
if str(step.get("label") or "") == "Purge concluído" and step.get("status") == "ok":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_desk_phase(job_id: str) -> dict[str, Any] | None:
|
||||||
|
job = _load_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
if _desk_already_done(job):
|
||||||
|
if job["status"] != "done":
|
||||||
|
_set_job(job_id, status="done")
|
||||||
|
return _load_job(job_id)
|
||||||
|
|
||||||
|
domain = job["domain"]
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
desk_counts, desk_timeline = vm112_domains.purge_desk_timeline(conn, domain)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
for step in desk_timeline:
|
||||||
|
_upsert_step(job_id, step)
|
||||||
|
|
||||||
|
total_desk = sum(desk_counts.values())
|
||||||
|
_upsert_step(
|
||||||
|
job_id,
|
||||||
|
vm112_domains._timeline_entry("Purge concluído", "ok", f"Desk: {total_desk} registo(s)"),
|
||||||
|
)
|
||||||
|
_set_job(job_id, status="done", desk=desk_counts)
|
||||||
|
return _load_job(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
def recover_job(job_id: str, domain: str | None = None) -> dict[str, Any] | None:
|
||||||
|
"""Finaliza job quando VM112 já removeu o domínio (ex.: API reiniciada)."""
|
||||||
|
job = _load_job(job_id)
|
||||||
|
if not job:
|
||||||
|
if not domain:
|
||||||
|
return None
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
if vm112_domains.domain_exists_on_vm112(domain):
|
||||||
|
return None
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
desk_counts, desk_timeline = vm112_domains.purge_desk_timeline(conn, domain)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
total_desk = sum(desk_counts.values())
|
||||||
|
timeline = [
|
||||||
|
vm112_domains._timeline_entry(
|
||||||
|
"Purge recuperado",
|
||||||
|
"ok",
|
||||||
|
"Domínio já ausente na VM112",
|
||||||
|
),
|
||||||
|
*desk_timeline,
|
||||||
|
vm112_domains._timeline_entry("Purge concluído", "ok", f"Desk: {total_desk} registo(s)"),
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"id": job_id,
|
||||||
|
"job_id": job_id,
|
||||||
|
"domain": domain,
|
||||||
|
"status": "done",
|
||||||
|
"timeline": timeline,
|
||||||
|
"elapsed_vm112": 0,
|
||||||
|
"desk": desk_counts,
|
||||||
|
"vm112": {"ok": True, "recovered": True},
|
||||||
|
"error": None,
|
||||||
|
"by": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if job["status"] in ("done", "error"):
|
||||||
|
return job
|
||||||
|
|
||||||
|
domain = (domain or job["domain"]).lower().strip()
|
||||||
|
if vm112_domains.domain_exists_on_vm112(domain):
|
||||||
|
return job
|
||||||
|
|
||||||
|
_upsert_step(
|
||||||
|
job_id,
|
||||||
|
vm112_domains._timeline_entry(
|
||||||
|
"Purge VM112",
|
||||||
|
"ok",
|
||||||
|
"Domínio já removido na VM112 (recuperação)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return _finish_desk_phase(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_job(job_id: str, domain: str, root_password: str, username: str) -> None:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
try:
|
||||||
|
_set_job(job_id, status="running")
|
||||||
|
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
if not vm112_domains.verify_root_password(conn, root_password):
|
||||||
|
step = vm112_domains._timeline_entry("Validação Root", "fail", "Senha Root incorrecta")
|
||||||
|
_upsert_step(job_id, step)
|
||||||
|
_set_job(job_id, status="error", error="Senha Root incorrecta")
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
_upsert_step(job_id, vm112_domains._timeline_entry("Validação Root + confirmação", "ok"))
|
||||||
|
_upsert_step(
|
||||||
|
job_id,
|
||||||
|
vm112_domains._timeline_entry(
|
||||||
|
"Purge VM112 — em execução",
|
||||||
|
"running",
|
||||||
|
"Carbonio, site, portal, Cloudflare, Traefik…",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
vm112_result: dict[str, Any] = {"ok": False}
|
||||||
|
vm112_banner_marked = False
|
||||||
|
for kind, payload in vm112_domains.purge_vm112_with_poll(domain):
|
||||||
|
if kind == "step":
|
||||||
|
if not vm112_banner_marked:
|
||||||
|
_upsert_step(
|
||||||
|
job_id,
|
||||||
|
vm112_domains._timeline_entry(
|
||||||
|
"Purge VM112 — em execução", "ok", "Passos abaixo",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
vm112_banner_marked = True
|
||||||
|
_upsert_step(job_id, payload)
|
||||||
|
elif kind == "heartbeat":
|
||||||
|
_set_job(job_id, elapsed_vm112=int(payload.get("elapsed") or 0))
|
||||||
|
elif kind == "final":
|
||||||
|
vm112_result = payload
|
||||||
|
break
|
||||||
|
|
||||||
|
if not vm112_result.get("ok", False):
|
||||||
|
step = vm112_domains._timeline_entry(
|
||||||
|
"Purge VM112",
|
||||||
|
"fail",
|
||||||
|
str(vm112_result.get("error") or "falhou"),
|
||||||
|
)
|
||||||
|
_upsert_step(job_id, step)
|
||||||
|
_set_job(job_id, status="error", error=str(vm112_result.get("error") or "falhou"))
|
||||||
|
return
|
||||||
|
|
||||||
|
_set_job(job_id, vm112=vm112_result)
|
||||||
|
_finish_desk_phase(job_id)
|
||||||
|
except Exception as exc:
|
||||||
|
err = str(exc) or "erro inesperado"
|
||||||
|
_upsert_step(
|
||||||
|
job_id,
|
||||||
|
vm112_domains._timeline_entry("Purge VM112", "fail", err),
|
||||||
|
)
|
||||||
|
_set_job(job_id, status="error", error=err)
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
def get_job_public(job_id: str) -> dict[str, Any] | None:
|
||||||
|
job = _load_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
if job["status"] == "running":
|
||||||
|
try:
|
||||||
|
if not vm112_domains.domain_exists_on_vm112(job["domain"]):
|
||||||
|
job = recover_job(job_id) or job
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def list_jobs(limit: int = 100, offset: int = 0) -> dict[str, Any]:
|
||||||
|
_ensure_schema()
|
||||||
|
limit = max(1, min(int(limit), 500))
|
||||||
|
offset = max(0, int(offset))
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM vm112_purge_jobs").fetchone()[0]
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, domain, status, by_user, created_at, updated_at,
|
||||||
|
elapsed_vm112, desk_json, error
|
||||||
|
FROM vm112_purge_jobs
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
jobs = []
|
||||||
|
for row in rows:
|
||||||
|
desk = json.loads(row["desk_json"] or "{}")
|
||||||
|
desk_total = sum(int(v or 0) for v in desk.values())
|
||||||
|
jobs.append(
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"job_id": row["id"],
|
||||||
|
"domain": row["domain"],
|
||||||
|
"status": row["status"],
|
||||||
|
"by": row["by_user"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
"elapsed_vm112": int(row["elapsed_vm112"] or 0),
|
||||||
|
"desk": desk,
|
||||||
|
"desk_removed_total": desk_total,
|
||||||
|
"error": row["error"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"jobs": jobs, "total": int(total), "limit": limit, "offset": offset}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
101
ligbox-ops-platform/api/app/vm112_purge_stream.py
Normal file
101
ligbox-ops-platform/api/app/vm112_purge_stream.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""SSE stream — purge domínio VM112 + Desk (Spec 017 Fase 2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app import auth, vm112_domains
|
||||||
|
|
||||||
|
|
||||||
|
def _sse(payload: dict[str, Any]) -> str:
|
||||||
|
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def purge_sse_generator(domain: str, root_password: str, username: str) -> Iterator[str]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
|
||||||
|
conn = auth.db()
|
||||||
|
try:
|
||||||
|
if not vm112_domains.verify_root_password(conn, root_password):
|
||||||
|
yield _sse({
|
||||||
|
"type": "error",
|
||||||
|
"step": vm112_domains._timeline_entry("Validação Root", "fail", "Senha Root incorrecta"),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
yield _sse({"type": "step", "step": vm112_domains._timeline_entry("Validação Root + confirmação", "ok")})
|
||||||
|
yield _sse({
|
||||||
|
"type": "step",
|
||||||
|
"step": vm112_domains._timeline_entry(
|
||||||
|
"Purge VM112 — em execução",
|
||||||
|
"running",
|
||||||
|
"Carbonio, site, portal, Cloudflare, Traefik…",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
vm112_result: dict[str, Any] = {"ok": False}
|
||||||
|
for kind, payload in vm112_domains.purge_vm112_with_poll(domain, poll_interval=2.0):
|
||||||
|
if kind == "step":
|
||||||
|
yield _sse({"type": "step", "step": payload, "phase": "vm112"})
|
||||||
|
elif kind == "heartbeat":
|
||||||
|
yield _sse({
|
||||||
|
"type": "heartbeat",
|
||||||
|
"elapsed": payload.get("elapsed", 0),
|
||||||
|
"label": "Purge VM112 — em execução",
|
||||||
|
})
|
||||||
|
elif kind == "final":
|
||||||
|
vm112_result = payload
|
||||||
|
if not vm112_result.get("ok", False):
|
||||||
|
yield _sse({
|
||||||
|
"type": "error",
|
||||||
|
"step": vm112_domains._timeline_entry(
|
||||||
|
"Purge VM112",
|
||||||
|
"fail",
|
||||||
|
str(vm112_result.get("error") or "falhou"),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
break
|
||||||
|
|
||||||
|
conn = auth.db()
|
||||||
|
desk_counts: dict[str, int] = {}
|
||||||
|
try:
|
||||||
|
domain_l = domain.lower().strip()
|
||||||
|
like = f"%{domain_l}%"
|
||||||
|
desk_steps = (
|
||||||
|
("Desk — webhook_events", "webhook_events", "DELETE FROM webhook_events WHERE payload LIKE ?", (like,)),
|
||||||
|
("Desk — tickets", "tickets", "DELETE FROM tickets WHERE subject LIKE ? OR payload LIKE ?", (like, like)),
|
||||||
|
("Desk — audit_domains", "audit_domains", "DELETE FROM audit_domains WHERE domain = ?", (domain_l,)),
|
||||||
|
("Desk — assist_sessions", "assist_sessions", "DELETE FROM assist_sessions WHERE domain = ?", (domain_l,)),
|
||||||
|
("Desk — audit_checks", "audit_checks", "DELETE FROM audit_checks WHERE domain = ?", (domain_l,)),
|
||||||
|
)
|
||||||
|
for label, key, sql, params in desk_steps:
|
||||||
|
yield _sse({"type": "step", "step": vm112_domains._timeline_entry(label, "running")})
|
||||||
|
n = conn.execute(sql, params).rowcount
|
||||||
|
desk_counts[key] = n
|
||||||
|
yield _sse({
|
||||||
|
"type": "step",
|
||||||
|
"step": vm112_domains._timeline_entry(label, "ok", f"{n} registo(s) removido(s)"),
|
||||||
|
"phase": "desk",
|
||||||
|
})
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
total_desk = sum(desk_counts.values())
|
||||||
|
done_step = vm112_domains._timeline_entry("Purge concluído", "ok", f"Desk: {total_desk} registo(s)")
|
||||||
|
yield _sse({
|
||||||
|
"type": "done",
|
||||||
|
"step": done_step,
|
||||||
|
"domain": domain,
|
||||||
|
"vm112": vm112_result,
|
||||||
|
"desk": desk_counts,
|
||||||
|
"by": username,
|
||||||
|
})
|
||||||
238
ligbox-ops-platform/api/app/wazuh_soc_store.py
Normal file
238
ligbox-ops-platform/api/app/wazuh_soc_store.py
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
"""Wazuh SOC — dados para Audit Overview (tenant VM104)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
WAZUH_TENANT_ID = 2
|
||||||
|
WAZUH_API_URL = os.getenv("WAZUH_API_URL", "https://10.10.10.104:55000/")
|
||||||
|
WAZUH_MIN_LEVEL = int(os.getenv("WAZUH_MIN_TICKET_LEVEL", "10"))
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_payload(raw: str | dict) -> dict:
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
return json.loads(raw or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def wazuh_api_status() -> dict:
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=5.0, verify=False) as client:
|
||||||
|
response = client.get(WAZUH_API_URL)
|
||||||
|
online = response.status_code in (200, 401)
|
||||||
|
return {
|
||||||
|
"reachable": True,
|
||||||
|
"http_status": response.status_code,
|
||||||
|
"api_online": online,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"reachable": False, "http_status": None, "api_online": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_alert_row(row: sqlite3.Row) -> dict:
|
||||||
|
payload = _parse_payload(row["payload"])
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
level = int(data.get("level") or 0)
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"event_type": row["event_type"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"level": level,
|
||||||
|
"rule_id": data.get("rule_id"),
|
||||||
|
"description": (data.get("description") or "").strip(),
|
||||||
|
"agent": (data.get("agent") or payload.get("domain") or "—").strip(),
|
||||||
|
"agent_ip": data.get("agent_ip"),
|
||||||
|
"srcip": data.get("srcip"),
|
||||||
|
"session_id": payload.get("session_id"),
|
||||||
|
"severity": _level_severity(level),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _level_severity(level: int) -> str:
|
||||||
|
if level >= 12:
|
||||||
|
return "critical"
|
||||||
|
if level >= WAZUH_MIN_LEVEL:
|
||||||
|
return "high"
|
||||||
|
if level >= 7:
|
||||||
|
return "medium"
|
||||||
|
return "low"
|
||||||
|
|
||||||
|
|
||||||
|
def list_wazuh_alerts(conn: sqlite3.Connection, limit: int = 200) -> list[dict]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, event_type, payload, created_at
|
||||||
|
FROM webhook_events
|
||||||
|
WHERE source = 'wazuh'
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [_parse_alert_row(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _in_hours(iso: str | None, hours: int) -> bool:
|
||||||
|
if not iso:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
return datetime.now(timezone.utc) - ts <= timedelta(hours=hours)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _build_agents(alerts: list[dict]) -> list[dict]:
|
||||||
|
agents: dict[str, dict] = {}
|
||||||
|
for alert in alerts:
|
||||||
|
name = alert["agent"] or "—"
|
||||||
|
entry = agents.setdefault(
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
"agent": name,
|
||||||
|
"agent_ip": alert.get("agent_ip"),
|
||||||
|
"alerts_count": 0,
|
||||||
|
"max_level": 0,
|
||||||
|
"last_seen": alert["created_at"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry["alerts_count"] += 1
|
||||||
|
entry["max_level"] = max(entry["max_level"], alert["level"])
|
||||||
|
if alert["created_at"] > entry["last_seen"]:
|
||||||
|
entry["last_seen"] = alert["created_at"]
|
||||||
|
if alert.get("agent_ip"):
|
||||||
|
entry["agent_ip"] = alert["agent_ip"]
|
||||||
|
return sorted(agents.values(), key=lambda a: (-a["max_level"], -a["alerts_count"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _overall_status(alerts: list[dict], api_online: bool, open_tickets: int) -> str:
|
||||||
|
recent_24h = [a for a in alerts if _in_hours(a["created_at"], 24)]
|
||||||
|
max_level_24h = max((a["level"] for a in recent_24h), default=0)
|
||||||
|
if max_level_24h >= 12 or open_tickets > 0:
|
||||||
|
return "critical"
|
||||||
|
if any(a["level"] >= WAZUH_MIN_LEVEL for a in recent_24h):
|
||||||
|
return "degraded"
|
||||||
|
if alerts and api_online:
|
||||||
|
return "healthy"
|
||||||
|
if api_online:
|
||||||
|
return "healthy"
|
||||||
|
if alerts:
|
||||||
|
return "degraded"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def wazuh_tenant_overview(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
tenant_id: int,
|
||||||
|
name: str,
|
||||||
|
ip: str,
|
||||||
|
) -> dict:
|
||||||
|
alerts = list_wazuh_alerts(conn, 200)
|
||||||
|
agents = _build_agents(alerts)
|
||||||
|
api = wazuh_api_status()
|
||||||
|
open_tickets = conn.execute(
|
||||||
|
"SELECT COUNT(*) c FROM tickets WHERE tenant_id = ? AND status NOT IN ('closed', 'resolved')",
|
||||||
|
(tenant_id,),
|
||||||
|
).fetchone()["c"]
|
||||||
|
alerts_24h = sum(1 for a in alerts if _in_hours(a["created_at"], 24))
|
||||||
|
alerts_high = sum(1 for a in alerts if a["level"] >= WAZUH_MIN_LEVEL)
|
||||||
|
status = _overall_status(alerts, api.get("api_online", False), open_tickets)
|
||||||
|
last_alert = alerts[0]["created_at"] if alerts else None
|
||||||
|
top_issues = [
|
||||||
|
{
|
||||||
|
"domain": a["agent"],
|
||||||
|
"check_id": f"L{a['level']}",
|
||||||
|
"status": a["severity"],
|
||||||
|
"message": a["description"] or a["event_type"],
|
||||||
|
"at": a["created_at"],
|
||||||
|
}
|
||||||
|
for a in alerts[:5]
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"name": name,
|
||||||
|
"ip": ip,
|
||||||
|
"kind": "wazuh_soc",
|
||||||
|
"status": status,
|
||||||
|
"api_online": api.get("api_online", False),
|
||||||
|
"http_status": api.get("http_status"),
|
||||||
|
"alerts_total": len(alerts),
|
||||||
|
"alerts_24h": alerts_24h,
|
||||||
|
"alerts_high": alerts_high,
|
||||||
|
"agents_count": len(agents),
|
||||||
|
"open_tickets": open_tickets,
|
||||||
|
"min_ticket_level": WAZUH_MIN_LEVEL,
|
||||||
|
"domains_count": 0,
|
||||||
|
"last_audit_at": last_alert,
|
||||||
|
"last_alert_at": last_alert,
|
||||||
|
"score": {
|
||||||
|
"pass": len(agents),
|
||||||
|
"warn": alerts_high,
|
||||||
|
"fail": open_tickets,
|
||||||
|
"total": max(len(alerts), 1),
|
||||||
|
},
|
||||||
|
"top_issues": top_issues,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def wazuh_tenant_details(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
tenant_id: int,
|
||||||
|
name: str,
|
||||||
|
ip: str,
|
||||||
|
) -> dict:
|
||||||
|
alerts = list_wazuh_alerts(conn, 100)
|
||||||
|
agents = _build_agents(alerts)
|
||||||
|
api = wazuh_api_status()
|
||||||
|
tickets = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, subject, status, created_at, session_id
|
||||||
|
FROM tickets WHERE tenant_id = ?
|
||||||
|
ORDER BY id DESC LIMIT 50
|
||||||
|
""",
|
||||||
|
(tenant_id,),
|
||||||
|
).fetchall()
|
||||||
|
ticket_rows = [dict(r) for r in tickets]
|
||||||
|
open_tickets = sum(1 for t in ticket_rows if t["status"] not in ("closed", "resolved"))
|
||||||
|
alerts_24h = [a for a in alerts if _in_hours(a["created_at"], 24)]
|
||||||
|
alerts_7d = [a for a in alerts if _in_hours(a["created_at"], 168)]
|
||||||
|
level_10_plus = sum(1 for a in alerts if a["level"] >= WAZUH_MIN_LEVEL)
|
||||||
|
level_12_plus = sum(1 for a in alerts if a["level"] >= 12)
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"name": name,
|
||||||
|
"ip": ip,
|
||||||
|
"kind": "wazuh_soc",
|
||||||
|
"generated_at": _now(),
|
||||||
|
"api": api,
|
||||||
|
"min_ticket_level": WAZUH_MIN_LEVEL,
|
||||||
|
"summary": {
|
||||||
|
"alerts_total": len(alerts),
|
||||||
|
"alerts_24h": len(alerts_24h),
|
||||||
|
"alerts_7d": len(alerts_7d),
|
||||||
|
"agents_total": len(agents),
|
||||||
|
"level_10_plus": level_10_plus,
|
||||||
|
"level_12_plus": level_12_plus,
|
||||||
|
"open_tickets": open_tickets,
|
||||||
|
"api_online": api.get("api_online", False),
|
||||||
|
},
|
||||||
|
"agents": agents,
|
||||||
|
"alerts": alerts,
|
||||||
|
"tickets": ticket_rows,
|
||||||
|
"domains": [],
|
||||||
|
}
|
||||||
8
ligbox-ops-platform/api/requirements.txt
Normal file
8
ligbox-ops-platform/api/requirements.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.32.1
|
||||||
|
httpx==0.28.1
|
||||||
|
redis==5.2.1
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.2.1
|
||||||
|
pyotp==2.9.0
|
||||||
0
ligbox-ops-platform/app/__init__.py
Normal file
0
ligbox-ops-platform/app/__init__.py
Normal file
256
ligbox-ops-platform/app/audit_store.py
Normal file
256
ligbox-ops-platform/app/audit_store.py
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
"""SQLite persistence for audit domains and checks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.collectors.base import CHECK_LABELS
|
||||||
|
|
||||||
|
ONBOARD_DOMAIN_EVENTS = frozenset({"account.created", "onboarding.completed"})
|
||||||
|
TENANT_ONBOARD = 1
|
||||||
|
|
||||||
|
STATUS_RANK = {"pass": 0, "skip": 1, "warn": 2, "error": 3, "fail": 4}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_payload(raw: str | None) -> dict:
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def init_audit_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_domains (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'onboarding',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(tenant_id, domain)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_checks (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
check_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
evidence TEXT,
|
||||||
|
checked_at TEXT NOT NULL,
|
||||||
|
UNIQUE(tenant_id, domain, check_id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_domains_from_webhooks(conn: sqlite3.Connection) -> int:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT event_type, payload FROM webhook_events
|
||||||
|
WHERE source = 'vm112-onboard'
|
||||||
|
ORDER BY id DESC LIMIT 500
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
added = 0
|
||||||
|
now = _now()
|
||||||
|
seen: set[tuple[int, str]] = set()
|
||||||
|
for row in rows:
|
||||||
|
if row["event_type"] not in ONBOARD_DOMAIN_EVENTS:
|
||||||
|
continue
|
||||||
|
payload = _parse_payload(row["payload"])
|
||||||
|
domain = (payload.get("domain") or "").strip().lower()
|
||||||
|
if not domain or len(domain) < 3:
|
||||||
|
continue
|
||||||
|
key = (TENANT_ONBOARD, domain)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO audit_domains (tenant_id, domain, source, created_at)
|
||||||
|
VALUES (?, ?, 'onboarding', ?)
|
||||||
|
""",
|
||||||
|
(TENANT_ONBOARD, domain, now),
|
||||||
|
)
|
||||||
|
if cur.rowcount:
|
||||||
|
added += 1
|
||||||
|
conn.commit()
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
def list_audit_domains(conn: sqlite3.Connection, tenant_id: int | None = None) -> list[dict]:
|
||||||
|
if tenant_id:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT tenant_id, domain, source, created_at FROM audit_domains WHERE tenant_id = ? ORDER BY domain",
|
||||||
|
(tenant_id,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT tenant_id, domain, source, created_at FROM audit_domains ORDER BY tenant_id, domain"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_check(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
tenant_id: int,
|
||||||
|
domain: str,
|
||||||
|
check_id: str,
|
||||||
|
status: str,
|
||||||
|
message: str,
|
||||||
|
evidence: dict | None,
|
||||||
|
checked_at: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_checks (tenant_id, domain, check_id, status, message, evidence, checked_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(tenant_id, domain, check_id) DO UPDATE SET
|
||||||
|
status = excluded.status,
|
||||||
|
message = excluded.message,
|
||||||
|
evidence = excluded.evidence,
|
||||||
|
checked_at = excluded.checked_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
tenant_id,
|
||||||
|
domain.lower(),
|
||||||
|
check_id,
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
json.dumps(evidence or {}),
|
||||||
|
checked_at or _now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_checks(conn: sqlite3.Connection, tenant_id: int, domain: str) -> list[dict]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT check_id, status, message, evidence, checked_at
|
||||||
|
FROM audit_checks WHERE tenant_id = ? AND domain = ?
|
||||||
|
ORDER BY check_id
|
||||||
|
""",
|
||||||
|
(tenant_id, domain.lower()),
|
||||||
|
).fetchall()
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
item["label"] = CHECK_LABELS.get(item["check_id"], item["check_id"])
|
||||||
|
item["evidence"] = _parse_payload(item.get("evidence"))
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_score(checks: list[dict]) -> dict[str, Any]:
|
||||||
|
total = len(CHECK_LABELS)
|
||||||
|
counts = {"pass": 0, "warn": 0, "fail": 0, "error": 0, "skip": 0}
|
||||||
|
worst = "pass"
|
||||||
|
for c in checks:
|
||||||
|
st = c.get("status") or "skip"
|
||||||
|
counts[st] = counts.get(st, 0) + 1
|
||||||
|
if STATUS_RANK.get(st, 0) > STATUS_RANK.get(worst, 0):
|
||||||
|
worst = st
|
||||||
|
if worst in ("fail", "error"):
|
||||||
|
overall = "critical"
|
||||||
|
elif worst == "warn":
|
||||||
|
overall = "degraded"
|
||||||
|
elif checks:
|
||||||
|
overall = "healthy"
|
||||||
|
else:
|
||||||
|
overall = "unknown"
|
||||||
|
return {
|
||||||
|
"pass": counts.get("pass", 0),
|
||||||
|
"warn": counts.get("warn", 0),
|
||||||
|
"fail": counts.get("fail", 0),
|
||||||
|
"error": counts.get("error", 0),
|
||||||
|
"skip": counts.get("skip", 0),
|
||||||
|
"total": total,
|
||||||
|
"overall_status": overall,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_overview(conn: sqlite3.Connection, tenant_id: int, name: str, ip: str) -> dict:
|
||||||
|
domains = list_audit_domains(conn, tenant_id)
|
||||||
|
if not domains:
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"name": name,
|
||||||
|
"ip": ip,
|
||||||
|
"status": "unknown",
|
||||||
|
"score": {"pass": 0, "warn": 0, "fail": 0, "total": 8},
|
||||||
|
"domains_count": 0,
|
||||||
|
"last_audit_at": None,
|
||||||
|
"top_issues": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
all_checks: list[dict] = []
|
||||||
|
last_audit = None
|
||||||
|
top_issues: list[dict] = []
|
||||||
|
domain_scores: list[dict] = []
|
||||||
|
for d in domains:
|
||||||
|
checks = get_checks(conn, tenant_id, d["domain"])
|
||||||
|
if not checks:
|
||||||
|
continue
|
||||||
|
all_checks.extend(checks)
|
||||||
|
domain_scores.append(aggregate_score(checks))
|
||||||
|
for c in checks:
|
||||||
|
if c["checked_at"] and (not last_audit or c["checked_at"] > last_audit):
|
||||||
|
last_audit = c["checked_at"]
|
||||||
|
if c["status"] in ("fail", "error", "warn"):
|
||||||
|
top_issues.append({
|
||||||
|
"domain": d["domain"],
|
||||||
|
"check_id": c["check_id"],
|
||||||
|
"status": c["status"],
|
||||||
|
"message": c.get("message"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if domain_scores:
|
||||||
|
worst = max(domain_scores, key=lambda s: STATUS_RANK.get(s["overall_status"], 0))
|
||||||
|
score = worst
|
||||||
|
else:
|
||||||
|
score = aggregate_score(all_checks)
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"name": name,
|
||||||
|
"ip": ip,
|
||||||
|
"status": score["overall_status"],
|
||||||
|
"score": {
|
||||||
|
"pass": score["pass"],
|
||||||
|
"warn": score["warn"],
|
||||||
|
"fail": score["fail"] + score["error"],
|
||||||
|
"total": score["total"],
|
||||||
|
},
|
||||||
|
"domains_count": len(domains),
|
||||||
|
"last_audit_at": last_audit,
|
||||||
|
"top_issues": top_issues[:5],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_overview(conn: sqlite3.Connection) -> dict:
|
||||||
|
tenants = conn.execute("SELECT id, name, ip FROM tenants ORDER BY id").fetchall()
|
||||||
|
return {
|
||||||
|
"generated_at": _now(),
|
||||||
|
"tenants": [tenant_overview(conn, t["id"], t["name"], t["ip"]) for t in tenants],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scorecard(conn: sqlite3.Connection, tenant_id: int, domain: str) -> dict:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
checks = get_checks(conn, tenant_id, domain)
|
||||||
|
score = aggregate_score(checks)
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"domain": domain,
|
||||||
|
"checked_at": max((c["checked_at"] for c in checks), default=None),
|
||||||
|
"overall_status": score["overall_status"],
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
3
ligbox-ops-platform/app/collectors/__init__.py
Normal file
3
ligbox-ops-platform/app/collectors/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .base import run_audit
|
||||||
|
|
||||||
|
__all__ = ["run_audit"]
|
||||||
55
ligbox-ops-platform/app/collectors/base.py
Normal file
55
ligbox-ops-platform/app/collectors/base.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""Run all read-only audit checks for a tenant domain."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import dns, vm112, webmail
|
||||||
|
|
||||||
|
CHECK_LABELS = {
|
||||||
|
"carbonio": "Carbonio domain",
|
||||||
|
"nginx_vhost": "carbonio-nginx vhost",
|
||||||
|
"cert_le": "Let's Encrypt certificate",
|
||||||
|
"dns_mx": "MX record",
|
||||||
|
"dns_spf": "SPF",
|
||||||
|
"dns_dkim": "DKIM",
|
||||||
|
"dns_dmarc": "DMARC",
|
||||||
|
"webmail_http": "Webmail HTTPS",
|
||||||
|
}
|
||||||
|
|
||||||
|
TENANT_API_BASE = {
|
||||||
|
1: None, # filled from env in run_audit
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_audit(
|
||||||
|
tenant_id: int,
|
||||||
|
domain: str,
|
||||||
|
*,
|
||||||
|
vm112_api: str | None = None,
|
||||||
|
mail_public_ip: str | None = None,
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
results: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
if tenant_id == 1:
|
||||||
|
api_base = vm112_api or "http://10.10.10.112:8090"
|
||||||
|
results.update(vm112.collect(domain, api_base))
|
||||||
|
|
||||||
|
results.update(dns.collect(domain, mail_public_ip=mail_public_ip))
|
||||||
|
results.update(webmail.collect(domain))
|
||||||
|
|
||||||
|
for check_id, label in CHECK_LABELS.items():
|
||||||
|
results.setdefault(
|
||||||
|
check_id,
|
||||||
|
{
|
||||||
|
"check_id": check_id,
|
||||||
|
"label": label,
|
||||||
|
"status": "skip",
|
||||||
|
"message": "Check not run",
|
||||||
|
"evidence": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
results[check_id]["label"] = label
|
||||||
|
|
||||||
|
return results
|
||||||
86
ligbox-ops-platform/app/collectors/dns.py
Normal file
86
ligbox-ops-platform/app/collectors/dns.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
"""Public DNS checks via dig (read-only)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _dig(*args: str) -> list[str]:
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["dig", "+short", *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=8,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return []
|
||||||
|
lines = [ln.strip().strip('"') for ln in proc.stdout.splitlines() if ln.strip()]
|
||||||
|
return lines
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _result(check_id: str, label: str, status: str, message: str, evidence: dict | None = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"check_id": check_id,
|
||||||
|
"label": label,
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"evidence": evidence or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect(domain: str, mail_public_ip: str | None = None) -> dict[str, dict[str, Any]]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
mail_host = f"mail.{domain}"
|
||||||
|
results: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
mx = _dig(domain, "MX")
|
||||||
|
mx_ok = any(mail_host in line or domain in line for line in mx)
|
||||||
|
results["dns_mx"] = _result(
|
||||||
|
"dns_mx",
|
||||||
|
"MX record",
|
||||||
|
"pass" if mx_ok else "fail",
|
||||||
|
f"MX: {', '.join(mx[:3]) or 'none'}",
|
||||||
|
{"records": mx},
|
||||||
|
)
|
||||||
|
|
||||||
|
txt_root = _dig(domain, "TXT")
|
||||||
|
spf = [t for t in txt_root if t.lower().startswith("v=spf1")]
|
||||||
|
results["dns_spf"] = _result(
|
||||||
|
"dns_spf",
|
||||||
|
"SPF",
|
||||||
|
"pass" if spf else "fail",
|
||||||
|
spf[0][:120] if spf else "SPF TXT not found",
|
||||||
|
{"records": spf},
|
||||||
|
)
|
||||||
|
|
||||||
|
dkim_name = f"default._domainkey.{domain}"
|
||||||
|
dkim = _dig(dkim_name, "TXT")
|
||||||
|
results["dns_dkim"] = _result(
|
||||||
|
"dns_dkim",
|
||||||
|
"DKIM",
|
||||||
|
"pass" if dkim else "fail",
|
||||||
|
"DKIM TXT present" if dkim else f"{dkim_name} not found",
|
||||||
|
{"records": dkim[:2]},
|
||||||
|
)
|
||||||
|
|
||||||
|
dmarc_name = f"_dmarc.{domain}"
|
||||||
|
dmarc = _dig(dmarc_name, "TXT")
|
||||||
|
results["dns_dmarc"] = _result(
|
||||||
|
"dns_dmarc",
|
||||||
|
"DMARC",
|
||||||
|
"pass" if dmarc else "warn",
|
||||||
|
dmarc[0][:120] if dmarc else "DMARC TXT not found",
|
||||||
|
{"records": dmarc},
|
||||||
|
)
|
||||||
|
|
||||||
|
if mail_public_ip:
|
||||||
|
a_mail = _dig(mail_host, "A")
|
||||||
|
if mail_public_ip not in a_mail and results["dns_mx"]["status"] == "pass":
|
||||||
|
results["dns_mx"]["status"] = "warn"
|
||||||
|
results["dns_mx"]["message"] += f" (A {mail_host}: {a_mail or 'none'})"
|
||||||
|
|
||||||
|
return results
|
||||||
67
ligbox-ops-platform/app/collectors/vm112.py
Normal file
67
ligbox-ops-platform/app/collectors/vm112.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""VM112 portal infrastructure checks (read-only API)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def _result(check_id: str, label: str, ok: bool | None, message: str, evidence: dict | None = None) -> dict[str, Any]:
|
||||||
|
if ok is True:
|
||||||
|
status = "pass"
|
||||||
|
elif ok is False:
|
||||||
|
status = "fail"
|
||||||
|
else:
|
||||||
|
status = "error"
|
||||||
|
return {
|
||||||
|
"check_id": check_id,
|
||||||
|
"label": label,
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"evidence": evidence or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect(domain: str, api_base: str) -> dict[str, dict[str, Any]]:
|
||||||
|
url = f"{api_base.rstrip('/')}/api/onboarding/infrastructure/status/{domain}"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=15.0) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
if response.status_code != 200:
|
||||||
|
err = _result("carbonio", "Carbonio domain", None, f"Portal API HTTP {response.status_code}")
|
||||||
|
return {
|
||||||
|
"carbonio": err,
|
||||||
|
"nginx_vhost": {**err, "check_id": "nginx_vhost", "label": "carbonio-nginx vhost"},
|
||||||
|
"cert_le": {**err, "check_id": "cert_le", "label": "Let's Encrypt certificate"},
|
||||||
|
}
|
||||||
|
data = response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
err = _result("carbonio", "Carbonio domain", None, str(exc))
|
||||||
|
return {
|
||||||
|
"carbonio": err,
|
||||||
|
"nginx_vhost": {**err, "check_id": "nginx_vhost", "label": "carbonio-nginx vhost"},
|
||||||
|
"cert_le": {**err, "check_id": "cert_le", "label": "Let's Encrypt certificate"},
|
||||||
|
}
|
||||||
|
|
||||||
|
steps = {s.get("id"): s for s in data.get("steps") or [] if isinstance(s, dict)}
|
||||||
|
|
||||||
|
def from_step(check_id: str, label: str, step_id: str) -> dict[str, Any]:
|
||||||
|
step = steps.get(step_id) or {}
|
||||||
|
return _result(
|
||||||
|
check_id,
|
||||||
|
label,
|
||||||
|
step.get("ok"),
|
||||||
|
step.get("message") or f"Step {step_id}",
|
||||||
|
{"step_id": step_id, "ready": data.get("ready")},
|
||||||
|
)
|
||||||
|
|
||||||
|
cert = from_step("cert_le", "Let's Encrypt certificate", "cert_san")
|
||||||
|
if cert["status"] == "pass":
|
||||||
|
cert["status"] = "pass"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"carbonio": from_step("carbonio", "Carbonio domain", "carbonio_domain"),
|
||||||
|
"nginx_vhost": from_step("nginx_vhost", "carbonio-nginx vhost", "carbonio_nginx_vhost"),
|
||||||
|
"cert_le": cert,
|
||||||
|
}
|
||||||
41
ligbox-ops-platform/app/collectors/webmail.py
Normal file
41
ligbox-ops-platform/app/collectors/webmail.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Webmail HTTPS check (read-only)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def collect(domain: str) -> dict[str, dict[str, Any]]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
url = f"https://mail.{domain}/"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=12.0, follow_redirects=True, verify=True) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
code = response.status_code
|
||||||
|
if 200 <= code < 400:
|
||||||
|
status, message = "pass", f"HTTP {code}"
|
||||||
|
elif code == 403:
|
||||||
|
status, message = "warn", f"HTTP {code}"
|
||||||
|
else:
|
||||||
|
status, message = "fail", f"HTTP {code}"
|
||||||
|
return {
|
||||||
|
"webmail_http": {
|
||||||
|
"check_id": "webmail_http",
|
||||||
|
"label": "Webmail HTTPS",
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"evidence": {"url": url, "status_code": code},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"webmail_http": {
|
||||||
|
"check_id": "webmail_http",
|
||||||
|
"label": "Webmail HTTPS",
|
||||||
|
"status": "fail",
|
||||||
|
"message": str(exc)[:200],
|
||||||
|
"evidence": {"url": url},
|
||||||
|
}
|
||||||
|
}
|
||||||
774
ligbox-ops-platform/app/main.py
Normal file
774
ligbox-ops-platform/app/main.py
Normal file
|
|
@ -0,0 +1,774 @@
|
||||||
|
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)}
|
||||||
552
ligbox-ops-platform/assets/app.js
Normal file
552
ligbox-ops-platform/assets/app.js
Normal file
|
|
@ -0,0 +1,552 @@
|
||||||
|
const API = '/api';
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const res = await fetch(`${API}${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${path}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString('pt-PT', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = {
|
||||||
|
view: 'dashboard',
|
||||||
|
ticketFilter: 'all',
|
||||||
|
sourceFilter: 'all',
|
||||||
|
eventSourceFilter: 'all',
|
||||||
|
selectedTicketId: null,
|
||||||
|
tickets: [],
|
||||||
|
summary: null,
|
||||||
|
scorecardTenant: null,
|
||||||
|
scorecardDomain: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const views = {
|
||||||
|
dashboard: document.getElementById('view-dashboard'),
|
||||||
|
overview: document.getElementById('view-overview'),
|
||||||
|
tickets: document.getElementById('view-tickets'),
|
||||||
|
events: document.getElementById('view-events'),
|
||||||
|
tenants: document.getElementById('view-tenants'),
|
||||||
|
infra: document.getElementById('view-infra'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function setView(name) {
|
||||||
|
state.view = name;
|
||||||
|
const titles = {
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
overview: 'Audit Overview',
|
||||||
|
tickets: 'Tickets',
|
||||||
|
events: 'Eventos webhook',
|
||||||
|
tenants: 'Tenants',
|
||||||
|
infra: 'Infraestrutura',
|
||||||
|
};
|
||||||
|
document.getElementById('page-title').textContent = titles[name] || 'Ligbox Ops';
|
||||||
|
document.querySelectorAll('.nav button').forEach((b) => {
|
||||||
|
b.classList.toggle('active', b.dataset.view === name);
|
||||||
|
});
|
||||||
|
Object.entries(views).forEach(([k, el]) => el?.classList.toggle('active', k === name));
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHealth() {
|
||||||
|
const el = document.getElementById('global-health');
|
||||||
|
try {
|
||||||
|
const h = await api('/health');
|
||||||
|
el.className = 'status-pill ok';
|
||||||
|
el.innerHTML = '<span class="dot"></span> API online';
|
||||||
|
return h;
|
||||||
|
} catch {
|
||||||
|
el.className = 'status-pill err';
|
||||||
|
el.innerHTML = '<span class="dot"></span> API offline';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDashboard() {
|
||||||
|
const box = document.getElementById('dashboard-content');
|
||||||
|
box.innerHTML = '<p class="loading">A carregar…</p>';
|
||||||
|
try {
|
||||||
|
const [summary, funnel, audit, vm112, wazuh] = await Promise.all([
|
||||||
|
api('/v1/desk/summary'),
|
||||||
|
api('/v1/onboard/funnel').catch(() => ({ stages: {}, active_sessions: [], sessions_total: 0 })),
|
||||||
|
api('/v1/audit/overview').catch(() => ({ tenants: [] })),
|
||||||
|
api('/v1/infra/vm112/status').catch(() => ({ error: 'indisponível' })),
|
||||||
|
api('/v1/infra/wazuh/status').catch(() => ({ error: 'indisponível' })),
|
||||||
|
]);
|
||||||
|
state.summary = summary;
|
||||||
|
const vmOk = vm112.vm112?.status === 'ok';
|
||||||
|
const wazuhOk = wazuh.http_status === 401 || wazuh.http_status === 200;
|
||||||
|
const sessions = funnel.active_sessions || [];
|
||||||
|
const sessionRows = sessions.slice(0, 8).map((s) => `
|
||||||
|
<div class="funnel-session ${s.stale ? 'stale' : ''}">
|
||||||
|
<div>
|
||||||
|
<strong>${esc(s.domain || '—')}</strong>
|
||||||
|
<div class="ticket-meta"><code>${esc((s.session_id || '').slice(0, 12))}</code> · ${esc(FUNNEL_LABELS[s.current_stage] || s.current_stage)}</div>
|
||||||
|
</div>
|
||||||
|
<div>${s.stale ? '<span class="badge review">inactivo</span>' : ''}${s.ticket_id ? `<span class="badge open">#${s.ticket_id}</span>` : ''}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
const auditCards = (audit.tenants || []).map((t) => `
|
||||||
|
<div class="health-card health-${esc(t.status)}">
|
||||||
|
<div class="health-card-head">
|
||||||
|
<strong>${esc(t.name)}</strong>
|
||||||
|
${healthBadge(t.status)}
|
||||||
|
</div>
|
||||||
|
<div class="health-score">${t.score?.pass ?? 0}/${t.score?.total ?? 8} checks OK</div>
|
||||||
|
<div class="ticket-meta">${t.domains_count || 0} domínio(s) · ${fmtDate(t.last_audit_at)}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
box.innerHTML = `
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><label>Abertos</label><strong>${summary.tickets_open}</strong></div>
|
||||||
|
<div class="stat"><label>Fechados</label><strong>${summary.tickets_closed}</strong></div>
|
||||||
|
<div class="stat"><label>Sessões funil</label><strong>${funnel.sessions_total || 0}</strong></div>
|
||||||
|
<div class="stat"><label>Eventos</label><strong>${summary.webhook_events}</strong></div>
|
||||||
|
</div>
|
||||||
|
${auditCards ? `<div class="health-grid" style="margin-bottom:1rem">${auditCards}</div>` : ''}
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Funil onboarding <span class="ticket-meta">48h</span></h3>
|
||||||
|
${funnelBarHtml(funnel.stages || {}, funnel.sessions_total || 0)}
|
||||||
|
${sessionRows ? `<h4 style="margin:1rem 0 0.5rem;font-size:0.85rem">Sessões activas</h4><div class="funnel-sessions">${sessionRows}</div>` : '<p class="loading" style="margin-top:1rem">Sem sessões recentes</p>'}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Tickets recentes</h3>
|
||||||
|
<div class="ticket-list">
|
||||||
|
${(summary.recent_tickets || []).map(ticketRowHtml).join('') || '<p class="loading">Sem tickets</p>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:1rem">
|
||||||
|
<h3>Infra</h3>
|
||||||
|
<div class="grid-2" style="gap:0.75rem">
|
||||||
|
<div class="infra-card">
|
||||||
|
<div><strong>VM112 Portal</strong><p class="ticket-meta">${vm112.vm112?.service || vm112.error || '—'}</p></div>
|
||||||
|
<span class="badge ${vmOk ? 'ok' : 'review'}">${vmOk ? 'online' : 'check'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="infra-card">
|
||||||
|
<div><strong>VM104 Wazuh</strong><p class="ticket-meta">API ${wazuh.http_status ?? '—'}</p></div>
|
||||||
|
<span class="badge ${wazuhOk ? 'ok' : 'review'}">${wazuhOk ? 'online' : 'check'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
box.querySelectorAll('.ticket-row').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
state.selectedTicketId = Number(btn.dataset.id);
|
||||||
|
setView('tickets');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
box.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceBadge(src) {
|
||||||
|
if (src === 'wazuh') return '<span class="badge wazuh">wazuh</span>';
|
||||||
|
if (src === 'vm112-onboard') return '<span class="badge onboard">onboard</span>';
|
||||||
|
return src ? `<span class="badge">${esc(src)}</span>` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityBadge(level) {
|
||||||
|
if (level == null) return '';
|
||||||
|
const n = Number(level);
|
||||||
|
let cls = 'sev-low';
|
||||||
|
if (n >= 12) cls = 'sev-critical';
|
||||||
|
else if (n >= 10) cls = 'sev-high';
|
||||||
|
else if (n >= 7) cls = 'sev-med';
|
||||||
|
return `<span class="badge ${cls}">L${n}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FUNNEL_LABELS = {
|
||||||
|
started: 'Iniciado',
|
||||||
|
domain_validated: 'Domínio OK',
|
||||||
|
dns_applied: 'DNS aplicado',
|
||||||
|
account_created: 'Conta criada',
|
||||||
|
infra_synced: 'Infra sync',
|
||||||
|
completed: 'Concluído',
|
||||||
|
failed: 'Falhou',
|
||||||
|
};
|
||||||
|
|
||||||
|
function funnelBarHtml(stages, total) {
|
||||||
|
const order = ['started', 'domain_validated', 'dns_applied', 'account_created', 'infra_synced', 'completed', 'failed'];
|
||||||
|
const max = Math.max(total || 1, ...order.map((k) => stages[k] || 0));
|
||||||
|
return order
|
||||||
|
.filter((k) => k !== 'failed' || (stages.failed || 0) > 0)
|
||||||
|
.map((key) => {
|
||||||
|
const n = stages[key] || 0;
|
||||||
|
const pct = max ? Math.round((n / max) * 100) : 0;
|
||||||
|
return `
|
||||||
|
<div class="funnel-row">
|
||||||
|
<span class="funnel-label">${FUNNEL_LABELS[key] || key}</span>
|
||||||
|
<div class="funnel-bar"><div class="funnel-fill" style="width:${pct}%"></div></div>
|
||||||
|
<strong class="funnel-count">${n}</strong>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineHtml(events) {
|
||||||
|
if (!events?.length) return '';
|
||||||
|
return `<ol class="timeline">${events
|
||||||
|
.map(
|
||||||
|
(e) => `
|
||||||
|
<li class="timeline-item">
|
||||||
|
<span class="timeline-dot"></span>
|
||||||
|
<div>
|
||||||
|
<strong>${esc(e.event_type)}</strong>
|
||||||
|
${e.stage ? `<span class="badge open">${esc(e.stage)}</span>` : ''}
|
||||||
|
<div class="ticket-meta">${fmtDate(e.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
</li>`
|
||||||
|
)
|
||||||
|
.join('')}</ol>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthBadge(status) {
|
||||||
|
const map = { healthy: 'ok', degraded: 'review', critical: 'closed', unknown: 'open' };
|
||||||
|
const cls = map[status] || 'open';
|
||||||
|
return `<span class="badge ${cls} health-${esc(status)}">${esc(status || 'unknown')}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStatusBadge(status) {
|
||||||
|
const cls = { pass: 'ok', warn: 'review', fail: 'closed', error: 'closed', skip: 'open' }[status] || 'open';
|
||||||
|
return `<span class="badge ${cls}">${esc(status)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketRowHtml(t) {
|
||||||
|
const review = t.needs_review ? '<span class="badge review">revisão</span>' : '';
|
||||||
|
const verified = t.account_verified ? '<span class="badge ok">verificado</span>' : '';
|
||||||
|
const sub = t.event === 'wazuh.alert'
|
||||||
|
? esc(t.description || t.subject)
|
||||||
|
: esc(t.domain || t.subject);
|
||||||
|
const meta = t.event === 'wazuh.alert'
|
||||||
|
? `${esc(t.agent || t.domain || '')} · ${fmtDate(t.created_at)}`
|
||||||
|
: `${esc(t.email || '')} · ${fmtDate(t.created_at)}`;
|
||||||
|
return `
|
||||||
|
<button type="button" class="ticket-row ${state.selectedTicketId === t.id ? 'selected' : ''}" data-id="${t.id}">
|
||||||
|
<span class="badge ${t.status}">${esc(t.status)}</span>
|
||||||
|
<div>
|
||||||
|
<div class="ticket-subject">${sub}</div>
|
||||||
|
<div class="ticket-meta">${meta}</div>
|
||||||
|
</div>
|
||||||
|
<div>${sourceBadge(t.source)}${severityBadge(t.severity)}${review}${verified}</div>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderOverview() {
|
||||||
|
const el = document.getElementById('overview-content');
|
||||||
|
const panel = document.getElementById('scorecard-panel');
|
||||||
|
el.innerHTML = '<p class="loading">A carregar overview…</p>';
|
||||||
|
try {
|
||||||
|
const data = await api('/v1/audit/overview');
|
||||||
|
const cards = (data.tenants || []).map((t) => {
|
||||||
|
const issues = (t.top_issues || [])
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((i) => `<li><code>${esc(i.domain)}</code> · ${esc(i.check_id)} — ${esc(i.message || i.status)}</li>`)
|
||||||
|
.join('');
|
||||||
|
const domains = [...new Set((t.top_issues || []).map((i) => i.domain).filter(Boolean))];
|
||||||
|
const domainBtns = domains.map((d) =>
|
||||||
|
`<button type="button" class="btn btn-ghost btn-sm" data-tenant="${t.tenant_id}" data-domain="${esc(d)}">${esc(d)}</button>`
|
||||||
|
).join(' ');
|
||||||
|
return `
|
||||||
|
<div class="card health-card health-${esc(t.status)}">
|
||||||
|
<div class="health-card-head">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin:0">${esc(t.name)}</h3>
|
||||||
|
<p class="ticket-meta">${esc(t.ip)} · ${t.domains_count || 0} domínio(s)</p>
|
||||||
|
</div>
|
||||||
|
${healthBadge(t.status)}
|
||||||
|
</div>
|
||||||
|
<div class="health-score">${t.score?.pass ?? 0}/${t.score?.total ?? 8} pass · ${t.score?.warn ?? 0} warn · ${t.score?.fail ?? 0} fail</div>
|
||||||
|
<p class="ticket-meta">Último audit: ${fmtDate(t.last_audit_at)}</p>
|
||||||
|
${issues ? `<ul class="issue-list">${issues}</ul>` : '<p class="loading">Sem issues ou aguardar 1.º ciclo audit</p>'}
|
||||||
|
<div class="actions" style="margin-top:0.75rem">${domainBtns || `<button type="button" class="btn btn-ghost btn-sm" data-run-audit="${t.tenant_id}">Correr audit cycle</button>`}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
el.innerHTML = cards
|
||||||
|
? `<div class="health-grid">${cards}</div>`
|
||||||
|
: '<p class="loading">Nenhum tenant auditado. Complete onboarding ou POST /audit/cycle.</p>';
|
||||||
|
el.querySelectorAll('[data-domain]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => loadScorecard(Number(btn.dataset.tenant), btn.dataset.domain));
|
||||||
|
});
|
||||||
|
el.querySelectorAll('[data-run-audit]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await api('/v1/audit/cycle', { method: 'POST' });
|
||||||
|
await renderOverview();
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (state.scorecardTenant && state.scorecardDomain) {
|
||||||
|
await loadScorecard(state.scorecardTenant, state.scorecardDomain, panel);
|
||||||
|
} else {
|
||||||
|
panel.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||||
|
panel.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScorecard(tenantId, domain, panelEl) {
|
||||||
|
const panel = panelEl || document.getElementById('scorecard-panel');
|
||||||
|
panel.style.display = 'block';
|
||||||
|
state.scorecardTenant = tenantId;
|
||||||
|
state.scorecardDomain = domain;
|
||||||
|
panel.innerHTML = '<p class="loading">A carregar scorecard…</p>';
|
||||||
|
try {
|
||||||
|
const sc = await api(`/v1/audit/tenants/${tenantId}/scorecard?domain=${encodeURIComponent(domain)}`);
|
||||||
|
const rows = (sc.checks || []).map((c) => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(c.label || c.check_id)}</td>
|
||||||
|
<td>${checkStatusBadge(c.status)}</td>
|
||||||
|
<td>${esc(c.message || '—')}</td>
|
||||||
|
<td>${fmtDate(c.checked_at)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="health-card-head">
|
||||||
|
<h3 style="margin:0">Scorecard — ${esc(domain)}</h3>
|
||||||
|
${healthBadge(sc.overall_status)}
|
||||||
|
</div>
|
||||||
|
<p class="ticket-meta">Tenant #${tenantId} · ${fmtDate(sc.checked_at)}</p>
|
||||||
|
<div class="table-wrap" style="margin-top:0.75rem">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Check</th><th>Status</th><th>Mensagem</th><th>Verificado</th></tr></thead>
|
||||||
|
<tbody>${rows || '<tr><td colspan="4">Sem checks</td></tr>'}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
} catch (e) {
|
||||||
|
panel.innerHTML = `<p class="loading">Erro scorecard: ${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderTickets() {
|
||||||
|
const listEl = document.getElementById('ticket-list');
|
||||||
|
const detailEl = document.getElementById('ticket-detail');
|
||||||
|
listEl.innerHTML = '<p class="loading">A carregar tickets…</p>';
|
||||||
|
try {
|
||||||
|
let q = '';
|
||||||
|
const params = [];
|
||||||
|
if (state.ticketFilter !== 'all') params.push(`status=${state.ticketFilter}`);
|
||||||
|
if (state.sourceFilter !== 'all') params.push(`source=${state.sourceFilter}`);
|
||||||
|
if (params.length) q = '?' + params.join('&');
|
||||||
|
const data = await api(`/v1/desk/tickets${q}`);
|
||||||
|
state.tickets = data.tickets || [];
|
||||||
|
listEl.innerHTML = state.tickets.length
|
||||||
|
? state.tickets.map(ticketRowHtml).join('')
|
||||||
|
: '<p class="loading">Nenhum ticket neste filtro</p>';
|
||||||
|
listEl.querySelectorAll('.ticket-row').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
state.selectedTicketId = Number(btn.dataset.id);
|
||||||
|
renderTicketDetail();
|
||||||
|
listEl.querySelectorAll('.ticket-row').forEach((r) => r.classList.remove('selected'));
|
||||||
|
btn.classList.add('selected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (state.selectedTicketId) await renderTicketDetail();
|
||||||
|
else detailEl.innerHTML = '<div class="card detail-panel"><p class="empty">Seleccione um ticket</p></div>';
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderTicketDetail() {
|
||||||
|
const detailEl = document.getElementById('ticket-detail');
|
||||||
|
if (!state.selectedTicketId) return;
|
||||||
|
detailEl.innerHTML = '<div class="card detail-panel"><p class="loading">A carregar…</p></div>';
|
||||||
|
try {
|
||||||
|
const t = await api(`/v1/desk/tickets/${state.selectedTicketId}`);
|
||||||
|
const timeline = t.timeline || t.related_events || [];
|
||||||
|
detailEl.innerHTML = `
|
||||||
|
<div class="card detail-panel">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem;flex-wrap:wrap">
|
||||||
|
<h3 style="margin:0">Ticket #${t.id}</h3>
|
||||||
|
<span class="badge ${t.status}">${esc(t.status)}</span>
|
||||||
|
</div>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Origem</dt><dd>${sourceBadge(t.source)}</dd>
|
||||||
|
<dt>Domínio/Agente</dt><dd>${esc(t.domain || t.agent || '—')}</dd>
|
||||||
|
<dt>Email</dt><dd>${esc(t.email || '—')}</dd>
|
||||||
|
<dt>Evento</dt><dd>${esc(t.event || '—')}</dd>
|
||||||
|
${t.ready_for_ops ? '<dt>Ops</dt><dd><span class="badge ok">ready for ops</span></dd>' : ''}
|
||||||
|
${t.severity != null ? `<dt>Severidade</dt><dd>${severityBadge(t.severity)}</dd>` : ''}
|
||||||
|
${t.rule_id ? `<dt>Regra</dt><dd>${esc(t.rule_id)}</dd>` : ''}
|
||||||
|
${t.description ? `<dt>Descrição</dt><dd>${esc(t.description)}</dd>` : ''}
|
||||||
|
${t.billing_state ? `<dt>Billing</dt><dd><span class="badge warn">${esc(t.billing_state)}</span></dd>` : ''}
|
||||||
|
${t.webmail_released != null ? `<dt>Webmail</dt><dd>${t.webmail_released ? 'Liberado' : 'Pendente'}</dd>` : ''}
|
||||||
|
<dt>${t.source === 'wazuh' ? 'Alert ID' : 'Sessão onboarding'}</dt><dd><code>${esc(t.session_id || '—')}</code></dd>
|
||||||
|
<dt>Verificado</dt><dd>${t.account_verified ? 'Sim' : 'Não'}</dd>
|
||||||
|
<dt>Revisão</dt><dd>${t.needs_review ? 'Necessária' : 'Não'}</dd>
|
||||||
|
<dt>Criado</dt><dd>${fmtDate(t.created_at)}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="actions">
|
||||||
|
${t.status === 'open'
|
||||||
|
? `<button type="button" class="btn btn-primary" data-action="close">Fechar ticket</button>`
|
||||||
|
: `<button type="button" class="btn btn-ghost" data-action="open">Reabrir ticket</button>`}
|
||||||
|
</div>
|
||||||
|
${timeline.length ? `<h3 style="margin-top:1.25rem">Timeline onboarding</h3>${timelineHtml(timeline)}` : ''}
|
||||||
|
<h3 style="margin-top:1.25rem">Payload</h3>
|
||||||
|
<pre class="raw">${esc(JSON.stringify(t.payload, null, 2))}</pre>
|
||||||
|
</div>`;
|
||||||
|
detailEl.querySelector('[data-action="close"]')?.addEventListener('click', () => updateTicketStatus('closed'));
|
||||||
|
detailEl.querySelector('[data-action="open"]')?.addEventListener('click', () => updateTicketStatus('open'));
|
||||||
|
} catch (e) {
|
||||||
|
detailEl.innerHTML = `<div class="card"><p class="loading">Erro: ${esc(e.message)}</p></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTicketStatus(status) {
|
||||||
|
await api(`/v1/desk/tickets/${state.selectedTicketId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
await renderTickets();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderEvents() {
|
||||||
|
const el = document.getElementById('events-content');
|
||||||
|
el.innerHTML = '<p class="loading">A carregar eventos…</p>';
|
||||||
|
try {
|
||||||
|
const srcQ = state.eventSourceFilter !== 'all' ? `?source=${state.eventSourceFilter}` : '';
|
||||||
|
const data = await api(`/v1/webhooks/events${srcQ}`);
|
||||||
|
const rows = (data.events || []).map((e) => {
|
||||||
|
const p = e.payload || {};
|
||||||
|
const dataObj = p.data || {};
|
||||||
|
return `<tr>
|
||||||
|
<td>${e.id}</td>
|
||||||
|
<td>${sourceBadge(e.source)}</td>
|
||||||
|
<td><span class="badge open">${esc(e.event_type)}</span> ${severityBadge(dataObj.level || e.severity)}</td>
|
||||||
|
<td>${esc(p.domain || '—')}</td>
|
||||||
|
<td><code>${esc((p.session_id || '').slice(0, 16))}</code></td>
|
||||||
|
<td>${fmtDate(e.created_at)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>ID</th><th>Origem</th><th>Evento</th><th>Agente/Domínio</th><th>Ref</th><th>Data</th></tr></thead>
|
||||||
|
<tbody>${rows || '<tr><td colspan="6">Sem eventos</td></tr>'}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderTenants() {
|
||||||
|
const el = document.getElementById('tenants-content');
|
||||||
|
el.innerHTML = '<p class="loading">A carregar…</p>';
|
||||||
|
try {
|
||||||
|
const data = await api('/v1/tenants');
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>ID</th><th>Nome</th><th>IP</th><th>Papel</th><th>Desde</th></tr></thead>
|
||||||
|
<tbody>${(data.tenants || []).map((t) => `
|
||||||
|
<tr>
|
||||||
|
<td>${t.id}</td>
|
||||||
|
<td>${esc(t.name)}</td>
|
||||||
|
<td><code>${esc(t.ip)}</code></td>
|
||||||
|
<td>${esc(t.role)}</td>
|
||||||
|
<td>${fmtDate(t.created_at)}</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderInfra() {
|
||||||
|
const el = document.getElementById('infra-content');
|
||||||
|
el.innerHTML = '<p class="loading">A verificar…</p>';
|
||||||
|
try {
|
||||||
|
const [vm112, wazuh, integrations] = await Promise.all([
|
||||||
|
api('/v1/infra/vm112/status'),
|
||||||
|
api('/v1/infra/wazuh/status'),
|
||||||
|
api('/v1/integrations'),
|
||||||
|
]);
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<h3>VM112 — Portal Onboard</h3>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>HTTP</dt><dd>${vm112.http_status ?? '—'}</dd>
|
||||||
|
<dt>Service</dt><dd>${esc(vm112.vm112?.service || vm112.error || '—')}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>VM104 — Wazuh SOC</h3>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>API</dt><dd>${wazuh.http_status ?? '—'}</dd>
|
||||||
|
<dt>Integração</dt><dd>webhook level ≥ 10 → VM122</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Integrações activas</h3>
|
||||||
|
<pre class="raw">${esc(JSON.stringify(integrations, null, 2))}</pre>
|
||||||
|
</div>`;
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
await loadHealth();
|
||||||
|
if (state.view === 'dashboard') await renderDashboard();
|
||||||
|
if (state.view === 'overview') await renderOverview();
|
||||||
|
if (state.view === 'tickets') await renderTickets();
|
||||||
|
if (state.view === 'events') await renderEvents();
|
||||||
|
if (state.view === 'tenants') await renderTenants();
|
||||||
|
if (state.view === 'infra') await renderInfra();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.nav button').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => setView(btn.dataset.view));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.filter-btn[data-filter]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
state.ticketFilter = btn.dataset.filter;
|
||||||
|
document.querySelectorAll('.filter-btn[data-filter]').forEach((b) => b.classList.toggle('active', b === btn));
|
||||||
|
renderTickets();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.filter-btn[data-source]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const kind = btn.dataset.kind || 'ticket';
|
||||||
|
if (kind === 'event') {
|
||||||
|
state.eventSourceFilter = btn.dataset.source;
|
||||||
|
document.querySelectorAll('.filter-btn[data-kind="event"]').forEach((b) => b.classList.toggle('active', b === btn));
|
||||||
|
renderEvents();
|
||||||
|
} else {
|
||||||
|
state.sourceFilter = btn.dataset.source;
|
||||||
|
document.querySelectorAll('.filter-btn[data-kind="ticket"]').forEach((b) => b.classList.toggle('active', b === btn));
|
||||||
|
renderTickets();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-refresh')?.addEventListener('click', refresh);
|
||||||
|
|
||||||
|
setView('dashboard');
|
||||||
|
setInterval(refresh, 30000);
|
||||||
417
ligbox-ops-platform/assets/styles.css
Normal file
417
ligbox-ops-platform/assets/styles.css
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f5f0e8;
|
||||||
|
--card: #fffdf9;
|
||||||
|
--accent: #5c2e2e;
|
||||||
|
--accent-hover: #3d1f1f;
|
||||||
|
--accent-soft: #f3e8e8;
|
||||||
|
--muted: #6b6560;
|
||||||
|
--border: #e0d5c8;
|
||||||
|
--ok: #2d6a4f;
|
||||||
|
--ok-bg: #e8f5ee;
|
||||||
|
--warn: #b5651d;
|
||||||
|
--warn-bg: #fef3e8;
|
||||||
|
--danger: #9b2226;
|
||||||
|
--sidebar-w: 220px;
|
||||||
|
--shadow: 0 4px 24px rgba(92, 46, 46, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'DM Sans', system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: #2a2520;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--sidebar-w) 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
padding: 1.25rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
padding: 0 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.12);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand p {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav { list-style: none; margin: 0; padding: 0.5rem 0; flex: 1; }
|
||||||
|
|
||||||
|
.nav button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
padding: 0.65rem 1.25rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav button:hover { background: rgba(255,255,255,0.08); color: #fff; }
|
||||||
|
.nav button.active { background: rgba(255,255,255,0.15); color: #fff; font-weight: 600; }
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.65;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main { padding: 1.5rem 1.75rem 2rem; overflow-x: auto; }
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header p { margin: 0.35rem 0 0; color: var(--muted); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.ok { background: var(--ok-bg); color: var(--ok); border-color: #b7dfc9; }
|
||||||
|
.status-pill.err { background: #fde8e8; color: var(--danger); border-color: #f5c2c2; }
|
||||||
|
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem 1.15rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat strong { font-size: 1.75rem; color: var(--accent); font-weight: 700; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.15rem 1.25rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 340px;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.shell { grid-template-columns: 1fr; }
|
||||||
|
.sidebar { flex-direction: row; flex-wrap: wrap; padding: 0.75rem; }
|
||||||
|
.brand { border: none; padding: 0 1rem; margin: 0; }
|
||||||
|
.nav { display: flex; flex-wrap: wrap; padding: 0; }
|
||||||
|
.nav button { width: auto; padding: 0.5rem 0.85rem; border-radius: 8px; }
|
||||||
|
.sidebar-footer { display: none; }
|
||||||
|
.grid-2 { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-list { display: flex; flex-direction: column; gap: 0.65rem; }
|
||||||
|
|
||||||
|
.ticket-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row:hover { border-color: var(--accent); box-shadow: var(--shadow); }
|
||||||
|
.ticket-row.selected { border-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.open { background: var(--warn-bg); color: var(--warn); }
|
||||||
|
.badge.closed { background: #eee; color: var(--muted); }
|
||||||
|
.badge.review { background: #fde8e8; color: var(--danger); }
|
||||||
|
.badge.wazuh { background: #ede9fe; color: #5b21b6; }
|
||||||
|
.badge.onboard { background: #e8f5ee; color: var(--ok); }
|
||||||
|
.badge.sev-critical { background: #fde8e8; color: #9b2226; }
|
||||||
|
.badge.sev-high { background: #fef3e8; color: var(--warn); }
|
||||||
|
.badge.sev-med { background: #fff8e6; color: #a16207; }
|
||||||
|
.badge.sev-low { background: #eee; color: var(--muted); }
|
||||||
|
.toolbar-sep { color: var(--border); padding: 0 0.25rem; }
|
||||||
|
|
||||||
|
.ticket-meta { font-size: 0.78rem; color: var(--muted); margin-top: 0.2rem; }
|
||||||
|
.ticket-subject { font-weight: 600; font-size: 0.92rem; color: #2a2520; }
|
||||||
|
|
||||||
|
.detail-panel { position: sticky; top: 1rem; }
|
||||||
|
|
||||||
|
.detail-panel .empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv { display: grid; grid-template-columns: 110px 1fr; gap: 0.35rem 0.75rem; font-size: 0.88rem; margin-bottom: 1rem; }
|
||||||
|
.kv dt { color: var(--muted); }
|
||||||
|
.kv dd { margin: 0; word-break: break-word; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary { background: var(--accent); color: #fff; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); }
|
||||||
|
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem; }
|
||||||
|
|
||||||
|
.event-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.event-list li {
|
||||||
|
padding: 0.65rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.event-list li:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||||||
|
th, td { text-align: left; padding: 0.65rem 0.5rem; border-bottom: 1px solid var(--border); }
|
||||||
|
th { color: var(--muted); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; }
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.loading { color: var(--muted); font-size: 0.9rem; padding: 1rem; }
|
||||||
|
|
||||||
|
.infra-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.raw {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view { display: none; }
|
||||||
|
.view.active { display: block; }
|
||||||
|
|
||||||
|
.funnel-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 7.5rem 1fr 2rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.funnel-label { color: var(--muted); }
|
||||||
|
.funnel-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.funnel-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), #8b4513);
|
||||||
|
border-radius: 999px;
|
||||||
|
min-width: 4px;
|
||||||
|
}
|
||||||
|
.funnel-count { text-align: right; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.funnel-sessions { margin-top: 0.5rem; }
|
||||||
|
.funnel-session {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.funnel-session.stale { opacity: 0.65; }
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 0.5rem;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 0 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
.timeline-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: -0.45rem;
|
||||||
|
top: 0.35rem;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-card {
|
||||||
|
border-left: 4px solid var(--border);
|
||||||
|
}
|
||||||
|
.health-card.health-healthy { border-left-color: var(--ok); }
|
||||||
|
.health-card.health-degraded { border-left-color: var(--warn); }
|
||||||
|
.health-card.health-critical { border-left-color: var(--danger); }
|
||||||
|
|
||||||
|
.health-card-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-score {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-list {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
25
ligbox-ops-platform/deploy/vm112-spec022/admin_accounts.py
Normal file
25
ligbox-ops-platform/deploy/vm112-spec022/admin_accounts.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""API admin — conta Carbonio individual (Desk Spec 022)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services import carbonio, domain_orchestration
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/accounts", tags=["admin-accounts"])
|
||||||
|
|
||||||
|
|
||||||
|
def require_api_key(x_api_key: str | None = Header(default=None, alias="X-Api-Key")):
|
||||||
|
if x_api_key != settings.admin_api_key:
|
||||||
|
raise HTTPException(401, "API key inválida")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{email}/delete", dependencies=[Depends(require_api_key)])
|
||||||
|
def delete_account(email: str):
|
||||||
|
email = email.lower().strip()
|
||||||
|
try:
|
||||||
|
return domain_orchestration.delete_carbonio_account(email)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e)) from e
|
||||||
|
except carbonio.CarbonioError as e:
|
||||||
|
raise HTTPException(400, str(e)) from e
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
|
||||||
|
def delete_carbonio_account(email: str) -> dict:
|
||||||
|
"""Remove uma conta Carbonio (zmprov da) — Spec 022."""
|
||||||
|
email = email.lower().strip()
|
||||||
|
if "@" not in email:
|
||||||
|
raise ValueError("e-mail inválido")
|
||||||
|
domain = email.split("@", 1)[1]
|
||||||
|
assert_purge_allowed(domain)
|
||||||
|
if not carbonio.account_exists(email):
|
||||||
|
return {"ok": True, "email": email, "message": "Conta já não existia", "skipped": True}
|
||||||
|
code, out, err = carbonio._zmprov_run("da", email, log_cmd=True)
|
||||||
|
if code != 0 and not carbonio._is_missing_account(err, out):
|
||||||
|
raise carbonio.CarbonioError(err or out or f"zmprov da falhou para {email}")
|
||||||
|
carbonio.invalidate_domain_cache(domain)
|
||||||
|
return {"ok": True, "email": email, "message": f"Conta {email} removida do Carbonio", "rc": code}
|
||||||
47
ligbox-ops-platform/deploy/vm112-wizard-security/README.md
Normal file
47
ligbox-ops-platform/deploy/vm112-wizard-security/README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# VM112 — Wizard Cybersecurity (Spec 021)
|
||||||
|
|
||||||
|
Pacote de referência para instalar na VM112 (`/opt/ligbox-wizard`).
|
||||||
|
|
||||||
|
## Componentes
|
||||||
|
|
||||||
|
| Ficheiro | Função |
|
||||||
|
|----------|--------|
|
||||||
|
| `security_audit.py` | Middleware FastAPI — audita inputs (SQLi/XSS/path) |
|
||||||
|
| `security_webhook_client.py` | Envia eventos `security.*` para VM122 |
|
||||||
|
|
||||||
|
## Variáveis de ambiente (VM112)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DESK_SECURITY_WEBHOOK_URL=https://desk.ligbox.com.br/api/v1/webhooks/security
|
||||||
|
DESK_WEBHOOK_SECRET=<mesmo WEBHOOK_SECRET do Desk>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integração no wizard
|
||||||
|
|
||||||
|
```python
|
||||||
|
from security_audit import SecurityAuditMiddleware
|
||||||
|
from security_webhook_client import emit_security_event
|
||||||
|
|
||||||
|
app.add_middleware(SecurityAuditMiddleware, on_block=emit_security_event)
|
||||||
|
```
|
||||||
|
|
||||||
|
Em rotas de handoff (`/onboard-handoff`, `/consume`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
emit_security_event("security.handoff_rejected", session_id=..., domain=..., data={"reason": "expired"})
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSP (Traefik CT114)
|
||||||
|
|
||||||
|
Ver `traefik-csp-headers.example.yml` — aplicar no router do portal/wizard.
|
||||||
|
|
||||||
|
Report URI: `https://desk.ligbox.com.br/api/v1/security/csp-report`
|
||||||
|
|
||||||
|
## Teste rápido (Desk)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "https://desk.ligbox.com.br/api/v1/webhooks/security" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Webhook-Secret: $WEBHOOK_SECRET" \
|
||||||
|
-d '{"event":"security.input_blocked","session_id":"demo-001","domain":"evil.test","data":{"reason":"xss_pattern","severity":"high"}}'
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Middleware auditoria de inputs — espelho heurístico do VM122 (Spec 021)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse, Response
|
||||||
|
|
||||||
|
SQLI_PATTERNS = [
|
||||||
|
re.compile(r"'\s*or\s+", re.I),
|
||||||
|
re.compile(r"union\s+select", re.I),
|
||||||
|
re.compile(r";\s*drop\s+", re.I),
|
||||||
|
re.compile(r"1\s*=\s*1", re.I),
|
||||||
|
re.compile(r"--\s*$"),
|
||||||
|
]
|
||||||
|
XSS_PATTERNS = [
|
||||||
|
re.compile(r"<\s*script", re.I),
|
||||||
|
re.compile(r"javascript\s*:", re.I),
|
||||||
|
re.compile(r"onerror\s*=", re.I),
|
||||||
|
re.compile(r"onload\s*=", re.I),
|
||||||
|
]
|
||||||
|
PATH_PATTERNS = [
|
||||||
|
re.compile(r"\.\./"),
|
||||||
|
re.compile(r"%2e%2e", re.I),
|
||||||
|
]
|
||||||
|
|
||||||
|
SKIP_PATHS = frozenset({"/health", "/metrics", "/favicon.ico"})
|
||||||
|
AUDIT_FIELDS = frozenset({"domain", "email", "company", "subdomain", "hostname", "mx", "txt"})
|
||||||
|
|
||||||
|
OnBlockCallback = Callable[[str, str, str, dict], Awaitable[None] | None]
|
||||||
|
|
||||||
|
|
||||||
|
def audit_value(value: str, *, field: str = "") -> dict:
|
||||||
|
text = (value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {"ok": True}
|
||||||
|
if len(text) > 2000:
|
||||||
|
return {"ok": False, "reason": "oversize", "severity": "high", "field": field}
|
||||||
|
for pat in SQLI_PATTERNS:
|
||||||
|
if pat.search(text):
|
||||||
|
return {"ok": False, "reason": "sql_injection_pattern", "severity": "high", "field": field}
|
||||||
|
for pat in XSS_PATTERNS:
|
||||||
|
if pat.search(text):
|
||||||
|
return {"ok": False, "reason": "xss_pattern", "severity": "high", "field": field}
|
||||||
|
for pat in PATH_PATTERNS:
|
||||||
|
if pat.search(text):
|
||||||
|
return {"ok": False, "reason": "path_traversal", "severity": "high", "field": field}
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_strings(obj, prefix: str = "") -> list[tuple[str, str]]:
|
||||||
|
out: list[tuple[str, str]] = []
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for k, v in obj.items():
|
||||||
|
key = f"{prefix}.{k}" if prefix else str(k)
|
||||||
|
if isinstance(v, str):
|
||||||
|
out.append((key, v))
|
||||||
|
elif isinstance(v, (dict, list)):
|
||||||
|
out.extend(_extract_strings(v, key))
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for i, v in enumerate(obj):
|
||||||
|
key = f"{prefix}[{i}]"
|
||||||
|
if isinstance(v, str):
|
||||||
|
out.append((key, v))
|
||||||
|
elif isinstance(v, (dict, list)):
|
||||||
|
out.extend(_extract_strings(v, key))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityAuditMiddleware(BaseHTTPMiddleware):
|
||||||
|
def __init__(self, app, on_block: OnBlockCallback | None = None):
|
||||||
|
super().__init__(app)
|
||||||
|
self.on_block = on_block
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
if request.method not in ("POST", "PUT", "PATCH"):
|
||||||
|
return await call_next(request)
|
||||||
|
if request.url.path in SKIP_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
body_bytes = await request.body()
|
||||||
|
if not body_bytes:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(body_bytes)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
session_id = None
|
||||||
|
domain = None
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
session_id = payload.get("session_id") or payload.get("sessionId")
|
||||||
|
domain = payload.get("domain")
|
||||||
|
|
||||||
|
for field, value in _extract_strings(payload):
|
||||||
|
base_field = field.split(".")[-1].split("[")[0]
|
||||||
|
if base_field not in AUDIT_FIELDS and len(value) < 8:
|
||||||
|
continue
|
||||||
|
result = audit_value(value, field=field)
|
||||||
|
if not result.get("ok"):
|
||||||
|
if self.on_block:
|
||||||
|
maybe = self.on_block(
|
||||||
|
"security.input_blocked",
|
||||||
|
session_id or "",
|
||||||
|
domain or "",
|
||||||
|
{**result, "endpoint": request.url.path},
|
||||||
|
)
|
||||||
|
if maybe is not None:
|
||||||
|
await maybe
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"error": "input_blocked", "reason": result.get("reason"), "field": field},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {"type": "http.request", "body": body_bytes, "more_body": False}
|
||||||
|
|
||||||
|
request = Request(request.scope, receive)
|
||||||
|
return await call_next(request)
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Cliente webhook segurança VM112 → VM122 (Spec 021)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
log = logging.getLogger("wizard.security")
|
||||||
|
|
||||||
|
WEBHOOK_URL = os.environ.get(
|
||||||
|
"DESK_SECURITY_WEBHOOK_URL",
|
||||||
|
"https://desk.ligbox.com.br/api/v1/webhooks/security",
|
||||||
|
)
|
||||||
|
WEBHOOK_SECRET = os.environ.get("DESK_WEBHOOK_SECRET", "")
|
||||||
|
|
||||||
|
|
||||||
|
def emit_security_event(
|
||||||
|
event: str,
|
||||||
|
session_id: str | None = None,
|
||||||
|
domain: str | None = None,
|
||||||
|
data: dict | None = None,
|
||||||
|
) -> bool:
|
||||||
|
if not event.startswith("security."):
|
||||||
|
log.warning("ignored non-security event: %s", event)
|
||||||
|
return False
|
||||||
|
if not WEBHOOK_SECRET:
|
||||||
|
log.warning("DESK_WEBHOOK_SECRET not set — skip %s", event)
|
||||||
|
return False
|
||||||
|
|
||||||
|
body = json.dumps({
|
||||||
|
"event": event,
|
||||||
|
"session_id": session_id,
|
||||||
|
"domain": domain,
|
||||||
|
"data": data or {},
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
WEBHOOK_URL,
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Webhook-Secret": WEBHOOK_SECRET,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||||
|
ok = 200 <= resp.status < 300
|
||||||
|
if not ok:
|
||||||
|
log.error("security webhook HTTP %s for %s", resp.status, event)
|
||||||
|
return ok
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
log.error("security webhook failed %s: %s", event, exc)
|
||||||
|
return False
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Traefik dynamic config — CSP para portal/wizard (CT114)
|
||||||
|
# Ajustar hostnames e validar libs externas antes de aplicar em produção.
|
||||||
|
|
||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
wizard-csp:
|
||||||
|
headers:
|
||||||
|
contentSecurityPolicy: >-
|
||||||
|
default-src 'self';
|
||||||
|
script-src 'self' 'unsafe-inline';
|
||||||
|
style-src 'self' 'unsafe-inline';
|
||||||
|
img-src 'self' data: https:;
|
||||||
|
connect-src 'self' https://desk.ligbox.com.br;
|
||||||
|
frame-ancestors 'none';
|
||||||
|
base-uri 'self';
|
||||||
|
form-action 'self';
|
||||||
|
report-uri https://desk.ligbox.com.br/api/v1/security/csp-report;
|
||||||
|
contentSecurityPolicyReportOnly: false
|
||||||
|
referrerPolicy: strict-origin-when-cross-origin
|
||||||
|
permissionsPolicy: "geolocation=(), microphone=(), camera=()"
|
||||||
|
customResponseHeaders:
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
X-Frame-Options: DENY
|
||||||
|
|
||||||
|
routers:
|
||||||
|
# Exemplo — anexar middleware ao router existente do wizard:
|
||||||
|
# middlewares:
|
||||||
|
# - wizard-csp
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Copiar para .env e preencher
|
||||||
|
MARIADB_PASSWORD=change-me-strong-password
|
||||||
47
ligbox-ops-platform/deploy/vm122-fossbilling/README.md
Normal file
47
ligbox-ops-platform/deploy/vm122-fossbilling/README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# FOSSBilling — VM122 (Spec 024)
|
||||||
|
|
||||||
|
Billing open source na mesma VM do Desk. **OpenPanel não corre aqui** — ver `specs/024-openpanel-fossbilling/spec.md`.
|
||||||
|
|
||||||
|
## Pré-requisitos
|
||||||
|
|
||||||
|
- Swap 2 GB recomendado: `fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile`
|
||||||
|
- Porta `8092` livre em `10.10.10.122`
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/ligbox-ops-platform/deploy/vm122-fossbilling
|
||||||
|
cp .env.example .env
|
||||||
|
# editar MARIADB_PASSWORD
|
||||||
|
docker compose -f docker-compose.fossbilling.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Abrir `http://10.10.10.122:8092` e completar o wizard:
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|-------|--------|
|
||||||
|
| DB host | `fossbilling-mariadb` |
|
||||||
|
| DB name | `fossbilling` |
|
||||||
|
| DB user | `fossbilling` |
|
||||||
|
| DB password | valor do `.env` |
|
||||||
|
|
||||||
|
## OpenPanel server manager
|
||||||
|
|
||||||
|
Após install, dentro do container ou volume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.fossbilling.yml exec fossbilling bash
|
||||||
|
wget -O /var/www/html/library/Server/Manager/OpenPanel.php \
|
||||||
|
https://raw.githubusercontent.com/stefanpejcic/FOSSBilling-OpenPanel/main/OpenPanel.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Configurar servidor OpenPanel (VM dedicada) na UI: System → Hosting plans and servers.
|
||||||
|
|
||||||
|
## Monitorização
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stats fossbilling fossbilling-mariadb
|
||||||
|
free -h
|
||||||
|
```
|
||||||
|
|
||||||
|
RAM alvo: stack billing < 1 GB em idle; VM122 total < 5 GB com Desk activo.
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
# FOSSBilling — VM122 (Spec 024)
|
||||||
|
# Uso: docker compose -f docker-compose.fossbilling.yml --env-file .env up -d
|
||||||
|
# Separado do Desk (docker-compose.mvp.yml)
|
||||||
|
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
fossbilling-mariadb:
|
||||||
|
image: mariadb:11-lts
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MARIADB_DATABASE: fossbilling
|
||||||
|
MARIADB_USER: fossbilling
|
||||||
|
MARIADB_PASSWORD: ${MARIADB_PASSWORD:?set MARIADB_PASSWORD in .env}
|
||||||
|
MARIADB_RANDOM_ROOT_PASSWORD: "1"
|
||||||
|
volumes:
|
||||||
|
- fossbilling-mariadb:/var/lib/mysql
|
||||||
|
command:
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
- --innodb-buffer-pool-size=256M
|
||||||
|
- --max-connections=50
|
||||||
|
mem_limit: 512m
|
||||||
|
networks: [billing]
|
||||||
|
|
||||||
|
fossbilling:
|
||||||
|
image: fossbilling/fossbilling:0.8.2
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "10.10.10.122:8092:80"
|
||||||
|
volumes:
|
||||||
|
- fossbilling-data:/var/www/html
|
||||||
|
depends_on:
|
||||||
|
- fossbilling-mariadb
|
||||||
|
mem_limit: 512m
|
||||||
|
networks: [billing]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
fossbilling-mariadb:
|
||||||
|
fossbilling-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
billing:
|
||||||
|
driver: bridge
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue