Initial import: obsidian-infra vault

This commit is contained in:
Ligbox Obsidian Vault 2026-06-19 17:26:42 +00:00
commit a8b62b2de1
227 changed files with 75842 additions and 0 deletions

View 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.

View file

@ -0,0 +1,3 @@
{
"feature_directory": "specs/003-desk-auth-rbac"
}

View file

@ -0,0 +1,191 @@
# Backlog — Ligbox Ops Platform (VM122)
**Última atualização:** 2026-06-17 (Specs **014025** + 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 (014025)
| # | Feature | Prioridade | Estado | Pendente principal |
|---|---------|------------|--------|-------------------|
| **007** | `mobile-push-notifications` | P1 | 📋 | Fases AC (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/`

View 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"]

View file

@ -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 T001T040
---
## 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

View file

@ -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).

View file

@ -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

View 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>
```

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

View file

@ -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\

View file

@ -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
```

View file

@ -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
```

View file

@ -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.*

View file

@ -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).

View file

@ -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/
```

View 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>
```

View file

@ -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
```

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

View file

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

View file

@ -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

View file

@ -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
```

View 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"]

View file

View 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

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

View 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()

View 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

View 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

View 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."}

View 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

View 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

View 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],
}

View 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()

View 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()

View 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,
}

View file

@ -0,0 +1,3 @@
from .base import run_audit
__all__ = ["run_audit"]

View 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

View 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

View 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,
}

View 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},
}
}

View 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")

View 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

View 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",
)

View 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

View 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(),
}

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

File diff suppressed because it is too large Load diff

View 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.",
}

View 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"),
}

View file

@ -0,0 +1 @@
"""Email migration module — Spec 019."""

View 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

View 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

View 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()

View 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",
}

View 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,
}

View file

@ -0,0 +1,3 @@
from app.modules.routes import router as modules_router
__all__ = ["modules_router"]

View 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]

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

View 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

View 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}"

View 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")

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

View 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,
}

View 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

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

View 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,
}

View 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}"

View 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

View 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

View 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()

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

View 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": [],
}

View 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

View file

View 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,
}

View file

@ -0,0 +1,3 @@
from .base import run_audit
__all__ = ["run_audit"]

View 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

View 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

View 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,
}

View 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},
}
}

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

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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);

View 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

View 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

View file

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

View 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"}}'
```

View file

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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,2 @@
# Copiar para .env e preencher
MARIADB_PASSWORD=change-me-strong-password

View 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.

View file

@ -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