chore: sync Console SSO, DNS viewer, specs e infra docs pendentes
Inclui console handoff Desk↔Console (Spec 019), melhorias DNS Viewer (037), OpenPanel/Nextcloud/VM116 deploy notes, contracts stack e sidebar actualizado. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
edffd8b3c0
commit
c1881f58e6
72 changed files with 5028 additions and 183 deletions
88
.cursor/rules/spec-driver-sync.mdc
Normal file
88
.cursor/rules/spec-driver-sync.mdc
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
---
|
||||||
|
description: Sincronização imediata Spec → Spec Driver, chat-bruto, Forgejo e GitHub
|
||||||
|
globs: specs/**/*,chat-bruto/**/*,portal/**/*
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Spec Driver — sincronização imediata (Roger)
|
||||||
|
|
||||||
|
**Spec Driver** = portal [spec.ligbox.com.br](https://spec.ligbox.com.br) + browser `/specs/` + sidebars + inventário VMs.
|
||||||
|
|
||||||
|
Sempre que **criar, ampliar ou modificar** qualquer spec (`specs/NNN-*`), **na mesma sessão**, antes de encerrar a tarefa:
|
||||||
|
|
||||||
|
## 1. Spec Kit (conteúdo)
|
||||||
|
|
||||||
|
- Atualizar artefatos SDD: `spec.md`, `tasks.md`, `plan.md`, `quickstart.md`, `contracts/` conforme a mudança.
|
||||||
|
- Em `spec.md`, secção **Decisões** / **Histórico** com o que foi decidido (data + resumo).
|
||||||
|
- Se houve discussão em chat: **salvar chat bruto** em `chat-bruto/` e referenciar na spec.
|
||||||
|
|
||||||
|
## 2. Chat bruto (obrigatório se existir discussão)
|
||||||
|
|
||||||
|
- Salvar em `chat-bruto/CHAT_BRUTO_<TEMA>_SPEC<NNN>_YYYYMMDD.txt` (e `.jsonl` se disponível).
|
||||||
|
- Atualizar ou criar `chat-bruto/CHAT_BRUTO_*_INDICE.txt` com link para a spec e ficheiros.
|
||||||
|
- Na spec, link explícito: `Ver chat bruto: chat-bruto/...`
|
||||||
|
- Copiar espelho para `obsidian-vault/ligbox-ops-platform/chat-bruto/` no CT130 quando aplicável.
|
||||||
|
|
||||||
|
## 3. Spec Driver (portal CT130) — imediato
|
||||||
|
|
||||||
|
No CT130 (`10.10.10.130`), após alterações no repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/ligbox-spec-hub
|
||||||
|
./portal/refresh-spec-driver.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
O script deve:
|
||||||
|
1. `generate-sidebars.sh` → `_sidebar.md`
|
||||||
|
2. `generate-specs-portal.py` → `/specs/index.html` (menu com **todas** as specs que têm `spec.md`, auto-descoberta)
|
||||||
|
3. Incluir **todos** os ficheiros em `chat-bruto/` no menu lateral
|
||||||
|
4. Atualizar contagem na homepage se existir placeholder estático
|
||||||
|
|
||||||
|
**Nunca** deixar specs novas só no disco sem regenerar o portal.
|
||||||
|
|
||||||
|
## 4. Git — Forgejo (fonte de verdade primária)
|
||||||
|
|
||||||
|
Repositório: `https://git.spec.ligbox.com.br/ligbox/ligbox-ops-platform`
|
||||||
|
|
||||||
|
Incluir no commit (quando aplicável):
|
||||||
|
- `specs/NNN-*/`
|
||||||
|
- `chat-bruto/`
|
||||||
|
- `contracts/`
|
||||||
|
- `docs/vms/` se VM inventário mudou
|
||||||
|
- `_sidebar.md` gerado
|
||||||
|
- `portal/html/specs/index.html` gerado
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add specs/ chat-bruto/ contracts/ docs/vms/ _sidebar.md portal/html/specs/index.html
|
||||||
|
git commit -m "spec NNN: <resumo da decisão>"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Esta regra **autoriza commit automático** para mudanças de spec (exceção à regra geral de só commitar quando pedido).
|
||||||
|
|
||||||
|
## 5. GitHub (espelho / segunda fonte de verdade)
|
||||||
|
|
||||||
|
Após push Forgejo, espelhar para GitHub (modelo de suporte + backup):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push github main # remote `github` — mirror ligbox-ops-platform
|
||||||
|
```
|
||||||
|
|
||||||
|
Se `github` remote não existir, criar mirror ou `git push` para repo `itecnologys/ligbox-ops-platform` (confirmar URL com Roger).
|
||||||
|
|
||||||
|
GitHub **não substitui** Forgejo; é réplica read-only ou bidirecional acordada.
|
||||||
|
|
||||||
|
## 6. Checklist de saída (não dispensar)
|
||||||
|
|
||||||
|
- [ ] Spec reflete decisões do chat
|
||||||
|
- [ ] Chat bruto salvo e linkado (se houve discussão)
|
||||||
|
- [ ] `refresh-spec-driver.sh` executado no CT130
|
||||||
|
- [ ] Spec visível em https://spec.ligbox.com.br/specs/
|
||||||
|
- [ ] Commit + push Forgejo
|
||||||
|
- [ ] Push/mirror GitHub
|
||||||
|
|
||||||
|
## 7. Falhas conhecidas a evitar
|
||||||
|
|
||||||
|
- Menu `/specs/` com lista hardcoded sem auto-descoberta → corrigir em `generate-specs-portal.py`
|
||||||
|
- Spec no repo mas portal mostra contagem antiga → regenerar portal
|
||||||
|
- Chat bruto só no laptop → não conta; tem de estar no repo + portal
|
||||||
72
_sidebar.md
72
_sidebar.md
|
|
@ -51,6 +51,7 @@
|
||||||
- [plan.md](specs/004-onboard-funnel-events/plan.md)
|
- [plan.md](specs/004-onboard-funnel-events/plan.md)
|
||||||
- [tasks.md](specs/004-onboard-funnel-events/tasks.md)
|
- [tasks.md](specs/004-onboard-funnel-events/tasks.md)
|
||||||
- [research.md](specs/004-onboard-funnel-events/research.md)
|
- [research.md](specs/004-onboard-funnel-events/research.md)
|
||||||
|
- [cloudflare-zone-provision.md](specs/004-onboard-funnel-events/cloudflare-zone-provision.md)
|
||||||
- **contracts/**
|
- **contracts/**
|
||||||
- [webhook-funnel-events](specs/004-onboard-funnel-events/contracts/webhook-funnel-events.md)
|
- [webhook-funnel-events](specs/004-onboard-funnel-events/contracts/webhook-funnel-events.md)
|
||||||
- **checklists/**
|
- **checklists/**
|
||||||
|
|
@ -77,14 +78,9 @@
|
||||||
- [📄 spec.md](specs/027-desk-rbac-function-matrix/spec.md)
|
- [📄 spec.md](specs/027-desk-rbac-function-matrix/spec.md)
|
||||||
- [quickstart.md](specs/027-desk-rbac-function-matrix/quickstart.md)
|
- [quickstart.md](specs/027-desk-rbac-function-matrix/quickstart.md)
|
||||||
- [data-model.md](specs/027-desk-rbac-function-matrix/data-model.md)
|
- [data-model.md](specs/027-desk-rbac-function-matrix/data-model.md)
|
||||||
|
- [ui-access-matrix.md](specs/027-desk-rbac-function-matrix/ui-access-matrix.md)
|
||||||
- **contracts/**
|
- **contracts/**
|
||||||
- [vm123-product-roles](specs/027-desk-rbac-function-matrix/contracts/vm123-product-roles.md)
|
- [vm123-product-roles](specs/027-desk-rbac-function-matrix/contracts/vm123-product-roles.md)
|
||||||
- **028-openpanel-ce-ligbox-reengineering**
|
|
||||||
- [📄 spec.md](specs/028-openpanel-ce-ligbox-reengineering/spec.md)
|
|
||||||
- [tasks.md](specs/028-openpanel-ce-ligbox-reengineering/tasks.md)
|
|
||||||
- [quickstart.md](specs/028-openpanel-ce-ligbox-reengineering/quickstart.md)
|
|
||||||
- [CONFIRMACAO-TESTE-API.md](specs/028-openpanel-ce-ligbox-reengineering/CONFIRMACAO-TESTE-API.md)
|
|
||||||
- [foss-bridge-api.md](specs/028-openpanel-ce-ligbox-reengineering/contracts/foss-bridge-api.md)
|
|
||||||
## VM123 — Finance / Console
|
## VM123 — Finance / Console
|
||||||
- **019-ops-console-active-operations**
|
- **019-ops-console-active-operations**
|
||||||
- [📄 spec.md](specs/019-ops-console-active-operations/spec.md)
|
- [📄 spec.md](specs/019-ops-console-active-operations/spec.md)
|
||||||
|
|
@ -144,3 +140,67 @@
|
||||||
- **021-wizard-cybersecurity-telemetry**
|
- **021-wizard-cybersecurity-telemetry**
|
||||||
- [📄 spec.md](specs/021-wizard-cybersecurity-telemetry/spec.md)
|
- [📄 spec.md](specs/021-wizard-cybersecurity-telemetry/spec.md)
|
||||||
- [tasks.md](specs/021-wizard-cybersecurity-telemetry/tasks.md)
|
- [tasks.md](specs/021-wizard-cybersecurity-telemetry/tasks.md)
|
||||||
|
- **028-openpanel-ce-ligbox-reengineering**
|
||||||
|
- [📄 spec.md](specs/028-openpanel-ce-ligbox-reengineering/spec.md)
|
||||||
|
- [tasks.md](specs/028-openpanel-ce-ligbox-reengineering/tasks.md)
|
||||||
|
- [quickstart.md](specs/028-openpanel-ce-ligbox-reengineering/quickstart.md)
|
||||||
|
- [CONFIRMACAO-TESTE-API.md](specs/028-openpanel-ce-ligbox-reengineering/CONFIRMACAO-TESTE-API.md)
|
||||||
|
- [DNS53_OPENPANEL_PORTA53.md](specs/028-openpanel-ce-ligbox-reengineering/DNS53_OPENPANEL_PORTA53.md)
|
||||||
|
- **contracts/**
|
||||||
|
- [foss-bridge-api](specs/028-openpanel-ce-ligbox-reengineering/contracts/foss-bridge-api.md)
|
||||||
|
- **029-agentic-ops-runbooks**
|
||||||
|
- [📄 spec.md](specs/029-agentic-ops-runbooks/spec.md)
|
||||||
|
- [tasks.md](specs/029-agentic-ops-runbooks/tasks.md)
|
||||||
|
- [quickstart.md](specs/029-agentic-ops-runbooks/quickstart.md)
|
||||||
|
- [agents-roster.md](specs/029-agentic-ops-runbooks/agents-roster.md)
|
||||||
|
- **contracts/**
|
||||||
|
- [agent-platform-api](specs/029-agentic-ops-runbooks/contracts/agent-platform-api.md)
|
||||||
|
- **029-tickets-workspace**
|
||||||
|
- [📄 spec.md](specs/029-tickets-workspace/spec.md)
|
||||||
|
- [ARCHITECTURE.md](specs/029-tickets-workspace/ARCHITECTURE.md)
|
||||||
|
- [CORRETIVO-20260619-tickets-rebuild.md](specs/029-tickets-workspace/CORRETIVO-20260619-tickets-rebuild.md)
|
||||||
|
- **030-agentic-ops-ui**
|
||||||
|
- [📄 spec.md](specs/030-agentic-ops-ui/spec.md)
|
||||||
|
- [tasks.md](specs/030-agentic-ops-ui/tasks.md)
|
||||||
|
- **contracts/**
|
||||||
|
- [agentic-ui-api](specs/030-agentic-ops-ui/contracts/agentic-ui-api.md)
|
||||||
|
- **design/**
|
||||||
|
- [wireframes](specs/030-agentic-ops-ui/design/wireframes.md)
|
||||||
|
- **032-purge-domain-extra-auth**
|
||||||
|
- [📄 spec.md](specs/032-purge-domain-extra-auth/spec.md)
|
||||||
|
- **033-desk-infra-console-ui**
|
||||||
|
- [📄 spec.md](specs/033-desk-infra-console-ui/spec.md)
|
||||||
|
- **034-nextcloud-carbonio-vm112-integration**
|
||||||
|
- [📄 spec.md](specs/034-nextcloud-carbonio-vm112-integration/spec.md)
|
||||||
|
- [tasks.md](specs/034-nextcloud-carbonio-vm112-integration/tasks.md)
|
||||||
|
- [quickstart.md](specs/034-nextcloud-carbonio-vm112-integration/quickstart.md)
|
||||||
|
- [infrastructure.md](specs/034-nextcloud-carbonio-vm112-integration/infrastructure.md)
|
||||||
|
- [034-console-process-ui.md](specs/034-nextcloud-carbonio-vm112-integration/034-console-process-ui.md)
|
||||||
|
- **contracts/**
|
||||||
|
- [nextcloud-provisioning-api](specs/034-nextcloud-carbonio-vm112-integration/contracts/nextcloud-provisioning-api.md)
|
||||||
|
- **035-ligbox-mail-bundles-foss-openpanel**
|
||||||
|
- [📄 spec.md](specs/035-ligbox-mail-bundles-foss-openpanel/spec.md)
|
||||||
|
- [tasks.md](specs/035-ligbox-mail-bundles-foss-openpanel/tasks.md)
|
||||||
|
- [domain-manager-console-ui.md](specs/035-ligbox-mail-bundles-foss-openpanel/domain-manager-console-ui.md)
|
||||||
|
- [foss-products.md](specs/035-ligbox-mail-bundles-foss-openpanel/foss-products.md)
|
||||||
|
- [ligbox-console-shell.md](specs/035-ligbox-mail-bundles-foss-openpanel/ligbox-console-shell.md)
|
||||||
|
- [ligbox-system-admin-ui.md](specs/035-ligbox-mail-bundles-foss-openpanel/ligbox-system-admin-ui.md)
|
||||||
|
- [user-self-service-ui.md](specs/035-ligbox-mail-bundles-foss-openpanel/user-self-service-ui.md)
|
||||||
|
- **035-ligbox-watchman**
|
||||||
|
- [📄 spec.md](specs/035-ligbox-watchman/spec.md)
|
||||||
|
- [tasks.md](specs/035-ligbox-watchman/tasks.md)
|
||||||
|
- [quickstart.md](specs/035-ligbox-watchman/quickstart.md)
|
||||||
|
- **contracts/**
|
||||||
|
- [status-snapshot](specs/035-ligbox-watchman/contracts/status-snapshot.md)
|
||||||
|
- **036-backstage-techdocs-speckit-companion**
|
||||||
|
- [📄 spec.md](specs/036-backstage-techdocs-speckit-companion/spec.md)
|
||||||
|
- [tasks.md](specs/036-backstage-techdocs-speckit-companion/tasks.md)
|
||||||
|
- [quickstart.md](specs/036-backstage-techdocs-speckit-companion/quickstart.md)
|
||||||
|
- **037-dns-multi-cloudflare-orchestration**
|
||||||
|
- [📄 spec.md](specs/037-dns-multi-cloudflare-orchestration/spec.md)
|
||||||
|
- [tasks.md](specs/037-dns-multi-cloudflare-orchestration/tasks.md)
|
||||||
|
- [cf-agents-sdk-architecture.md](specs/037-dns-multi-cloudflare-orchestration/cf-agents-sdk-architecture.md)
|
||||||
|
- [client-cf-account-lifecycle.md](specs/037-dns-multi-cloudflare-orchestration/client-cf-account-lifecycle.md)
|
||||||
|
- [implementation-phases.md](specs/037-dns-multi-cloudflare-orchestration/implementation-phases.md)
|
||||||
|
- [project-email-identity.md](specs/037-dns-multi-cloudflare-orchestration/project-email-identity.md)
|
||||||
|
- [wizard-validation-checklist.md](specs/037-dns-multi-cloudflare-orchestration/wizard-validation-checklist.md)
|
||||||
|
|
|
||||||
104
contracts/README.md
Normal file
104
contracts/README.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# Inventário Ligbox — Todas as VMs do Projecto
|
||||||
|
|
||||||
|
**Hub:** CT130 · `https://spec.ligbox.com.br`
|
||||||
|
**Git:** `https://git.spec.ligbox.com.br/ligbox/ligbox-ops-platform`
|
||||||
|
|
||||||
|
> As specs **não vivem só na VM122** — descrevem o **ecossistema completo**. O código de cada VM está em `deploy/vm*` e é sincronizado via Git Forgejo.
|
||||||
|
|
||||||
|
|
||||||
|
## Estrutura do monorepo
|
||||||
|
|
||||||
|
```
|
||||||
|
ligbox-ops-platform/
|
||||||
|
├── specs/ # Spec Kit — cruzam VMs
|
||||||
|
├── contracts/ # API/eventos partilhados
|
||||||
|
├── projects/
|
||||||
|
│ ├── wizard/ # VM112
|
||||||
|
│ ├── ops-desk/ # VM122
|
||||||
|
│ ├── finance/ # VM123
|
||||||
|
│ └── integrations/ # VM104 Wazuh
|
||||||
|
├── deploy/manifest.yaml
|
||||||
|
└── docs/vms/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mapa rápido
|
||||||
|
|
||||||
|
| VM/CT | IP | SSH WAN | Papel | Deploy no repo |
|
||||||
|
|-------|-----|---------|-------|----------------|
|
||||||
|
| **112** | 10.10.10.112 | :2512 | Wizard onboard + Carbonio mail | `projects/wizard/` |
|
||||||
|
| **122** | 10.10.10.122 | :2522 | Ops Desk API + worker + UI MVP | `projects/ops-desk/` |
|
||||||
|
| **123** | 10.10.10.123 | :2523 | FOSSBilling + Odoo + OpenPanel + Console UI | `projects/finance/` |
|
||||||
|
| **104** | 10.10.10.104 | :2504 | Wazuh SIEM | integração Spec 002, 019 |
|
||||||
|
| **114** | 10.10.10.114 | — | Traefik (CT) | `docs/network/TRAEFIK_*` |
|
||||||
|
| **116** | 10.10.10.116 | :2516 | RustDesk relay + portal remoto | — |
|
||||||
|
| **124** | 10.10.10.124 | :2524 | **Nextcloud Hub** — storage mail (Spec 034) | `deploy/vm116-nextcloud/` |
|
||||||
|
| **130** | 10.10.10.130 | :2530 | **Spec Hub** Git + Obsidian + Portal | CT130 local |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Specs por VM
|
||||||
|
|
||||||
|
### VM112 — Onboard / Mail
|
||||||
|
| Spec | Nome |
|
||||||
|
|------|------|
|
||||||
|
| 001 | webhook-vm112-integration |
|
||||||
|
| 017 | vm112-domain-orchestration |
|
||||||
|
| 022 | carbonio-account-exists-release |
|
||||||
|
| 025 | wizard-onboarding-continuity |
|
||||||
|
| 026 | purge-traefik-validation |
|
||||||
|
| 034 | nextcloud-carbonio-vm112-integration |
|
||||||
|
| 010 | desk-assist-takeover (fases VM112) |
|
||||||
|
|
||||||
|
→ [Ficha VM112](VM112.md)
|
||||||
|
|
||||||
|
### VM122 — Ops Desk (motor)
|
||||||
|
| Spec | Nome |
|
||||||
|
|------|------|
|
||||||
|
| 035 | ligbox-watchman (vigilância + alertas) |
|
||||||
|
| 003 | desk-auth-rbac |
|
||||||
|
| 004 | desk-account-management |
|
||||||
|
| 009 | ops-audit-overview |
|
||||||
|
| 010 | desk-assist-takeover |
|
||||||
|
| 012 | abandoned-onboarding-lead |
|
||||||
|
| 015 | desk-module-registry |
|
||||||
|
| 027 | desk-rbac-function-matrix |
|
||||||
|
| 029 | agentic ops |
|
||||||
|
|
||||||
|
→ [Ficha VM122](VM122.md)
|
||||||
|
|
||||||
|
### VM123 — Finance + Console
|
||||||
|
| Spec | Nome |
|
||||||
|
|------|------|
|
||||||
|
| 019 | ops-console-active-operations |
|
||||||
|
| 023 | billing-recurrence-desk-visibility |
|
||||||
|
| 024 | openpanel-fossbilling |
|
||||||
|
| 027 | desk-rbac (matriz VM123) |
|
||||||
|
|
||||||
|
→ [Ficha VM123](VM123.md)
|
||||||
|
|
||||||
|
### VM104 — Wazuh / SOC
|
||||||
|
| Spec | Nome |
|
||||||
|
|------|------|
|
||||||
|
| 002 | wazuh-integration |
|
||||||
|
| 019 | ops-console (deep-link SIEM) |
|
||||||
|
|
||||||
|
→ [Ficha VM104](VM104.md)
|
||||||
|
|
||||||
|
### CT130 — Spec Hub
|
||||||
|
| Spec | Nome |
|
||||||
|
|------|------|
|
||||||
|
| 031 | spec-hub-portal |
|
||||||
|
|
||||||
|
→ [Ficha CT130](CT130.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Porque parecia «só VM122»?
|
||||||
|
|
||||||
|
1. **Sync inicial** veio de `/opt/ligbox-ops-platform` na VM122 (monorepo central)
|
||||||
|
2. **Código VM112/123** está em `deploy/vm112-*` e `deploy/vm123-*` **dentro do mesmo repo** — não numa VM separada no vault
|
||||||
|
3. **Portal** listava specs flat — sem secção «Por VM»
|
||||||
|
4. **VM104** não tem pasta deploy no repo (integração via API/deep-link)
|
||||||
|
5. **CT130** foi criada depois — Spec 031 adicionada agora
|
||||||
|
|
||||||
|
**Próximo passo:** cada VM faz `git pull` do Forgejo — nunca editar disco solto.
|
||||||
60
contracts/manifest.yaml
Normal file
60
contracts/manifest.yaml
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Deploy manifest — Ligbox Ops Platform
|
||||||
|
# Fonte: CT130 Forgejo · git pull por VM
|
||||||
|
|
||||||
|
vm112:
|
||||||
|
tag: main
|
||||||
|
description: Wizard onboard + Carbonio mail
|
||||||
|
project: projects/wizard
|
||||||
|
paths:
|
||||||
|
- projects/wizard/deploy/vm112-spec022
|
||||||
|
- projects/wizard/deploy/vm112-wizard-security
|
||||||
|
deploy_to:
|
||||||
|
- /opt/ligbox-wizard-scripts
|
||||||
|
|
||||||
|
vm122:
|
||||||
|
tag: main
|
||||||
|
description: Ops Desk API + worker + frontend + Watchman (Spec 035)
|
||||||
|
project: projects/ops-desk
|
||||||
|
paths:
|
||||||
|
- projects/ops-desk
|
||||||
|
- projects/integrations/watchman
|
||||||
|
- deploy/watchman
|
||||||
|
- specs/035-ligbox-watchman/contracts/stack-services.yaml
|
||||||
|
deploy_to: /opt/ligbox-ops-platform
|
||||||
|
hub_mirror: /opt/ligbox-spec-hub/repos/ligbox-ops-platform/projects/ops-desk
|
||||||
|
note: Symlinks api/worker/frontend na raiz do repo apontam para projects/ops-desk/
|
||||||
|
|
||||||
|
vm123:
|
||||||
|
tag: main
|
||||||
|
description: Finance stack + console
|
||||||
|
project: projects/finance
|
||||||
|
paths:
|
||||||
|
- projects/finance/deploy/vm123-finance-stack
|
||||||
|
- projects/finance/app/vm123
|
||||||
|
- specs/019-ops-console-active-operations/deploy
|
||||||
|
deploy_to:
|
||||||
|
- /opt/vm123-finance-stack
|
||||||
|
- /opt/ligbox-ops-console
|
||||||
|
|
||||||
|
vm104:
|
||||||
|
tag: main
|
||||||
|
description: Wazuh SIEM integration
|
||||||
|
project: projects/integrations
|
||||||
|
paths:
|
||||||
|
- projects/integrations/wazuh
|
||||||
|
deploy_to: /opt/wazuh-integration
|
||||||
|
|
||||||
|
vm116:
|
||||||
|
tag: main
|
||||||
|
description: Nextcloud Hub — ampliar storage mail Carbonio (Spec 034)
|
||||||
|
paths:
|
||||||
|
- deploy/vm116-nextcloud
|
||||||
|
deploy_to: /opt/vm116-nextcloud
|
||||||
|
note: Nextcloud VM124 (10.10.10.124) — VM116=RustDesk; 2 vCPU 4GB 500GB
|
||||||
|
|
||||||
|
ct130:
|
||||||
|
tag: main
|
||||||
|
description: Spec Hub Git + Portal
|
||||||
|
paths:
|
||||||
|
- .
|
||||||
|
deploy_to: /opt/ligbox-spec-hub
|
||||||
274
contracts/stack-services.yaml
Normal file
274
contracts/stack-services.yaml
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
# Ligbox Stack Services — catálogo Watchman (Spec 035)
|
||||||
|
# Fonte de verdade partilhada com Desk stack_health (Spec 033)
|
||||||
|
# Versão: 1.0 draft — 2026-06-22
|
||||||
|
|
||||||
|
version: "1.0"
|
||||||
|
defaults:
|
||||||
|
timeout_sec: 8.0
|
||||||
|
failures_before_alert: 2
|
||||||
|
alert_repeat_minutes: 15
|
||||||
|
|
||||||
|
tiers:
|
||||||
|
critical:
|
||||||
|
interval_sec: 30
|
||||||
|
standard:
|
||||||
|
interval_sec: 60
|
||||||
|
slow:
|
||||||
|
interval_sec: 120
|
||||||
|
|
||||||
|
vms:
|
||||||
|
- vm: "112"
|
||||||
|
vm_label: "VM112 · Wizard & Mail"
|
||||||
|
ip: "10.10.10.112"
|
||||||
|
services:
|
||||||
|
- id: vm112-onboard-api
|
||||||
|
title: Onboard Portal API
|
||||||
|
spec: "001"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.112:8090/api/onboarding/health"
|
||||||
|
expect_status: [200]
|
||||||
|
|
||||||
|
- id: vm112-onboard-ui
|
||||||
|
title: Onboard Wizard UI
|
||||||
|
spec: "025"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "https://onboard.ligbox.com.br/"
|
||||||
|
expect_status: [200, 301, 302, 403]
|
||||||
|
|
||||||
|
- id: vm112-carbonio
|
||||||
|
title: Carbonio EmailServer
|
||||||
|
spec: "022"
|
||||||
|
kind: sw
|
||||||
|
tier: slow
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "https://10.10.10.112/"
|
||||||
|
verify_tls: false
|
||||||
|
expect_status: [200, 301, 302, 403, 404]
|
||||||
|
|
||||||
|
- id: vm112-domain-api
|
||||||
|
title: Domain Admin API
|
||||||
|
spec: "017"
|
||||||
|
kind: api
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.112:8090/api/onboarding/health"
|
||||||
|
expect_status: [200]
|
||||||
|
|
||||||
|
- vm: "114"
|
||||||
|
vm_label: "CT114 · Edge Traefik"
|
||||||
|
ip: "10.10.10.114"
|
||||||
|
services:
|
||||||
|
- id: vm114-traefik
|
||||||
|
title: Traefik API
|
||||||
|
spec: "026"
|
||||||
|
kind: sw
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.114:8080/api/overview"
|
||||||
|
expect_status: [200, 401, 403]
|
||||||
|
|
||||||
|
- id: vm114-desk-route
|
||||||
|
title: Router Desk (WAN)
|
||||||
|
spec: "026"
|
||||||
|
kind: integration
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "https://desk.ligbox.com.br/"
|
||||||
|
expect_status: [200, 301, 302]
|
||||||
|
|
||||||
|
- id: vm114-api-route
|
||||||
|
title: Router API Ops (WAN)
|
||||||
|
spec: "027"
|
||||||
|
kind: integration
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "https://api.ops.ligbox.com.br/health"
|
||||||
|
expect_status: [200]
|
||||||
|
|
||||||
|
- vm: "122"
|
||||||
|
vm_label: "VM122 · Ops Desk"
|
||||||
|
ip: "10.10.10.122"
|
||||||
|
services:
|
||||||
|
- id: vm122-desk-api
|
||||||
|
title: Desk API (FastAPI)
|
||||||
|
spec: "003"
|
||||||
|
kind: app
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://127.0.0.1:8080/health"
|
||||||
|
expect_status: [200]
|
||||||
|
|
||||||
|
- id: vm122-desk-ui
|
||||||
|
title: Desk Frontend
|
||||||
|
spec: "033"
|
||||||
|
kind: app
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.122:8091/"
|
||||||
|
expect_status: [200, 301, 302]
|
||||||
|
|
||||||
|
- id: vm122-redis
|
||||||
|
title: Redis
|
||||||
|
spec: "—"
|
||||||
|
kind: sw
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: redis
|
||||||
|
url: "redis://127.0.0.1:6379/0"
|
||||||
|
|
||||||
|
- id: vm122-docker-frontend
|
||||||
|
title: Docker container frontend
|
||||||
|
spec: "035"
|
||||||
|
kind: sw
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: docker
|
||||||
|
container: ligbox-ops-platform_frontend_1
|
||||||
|
expect_running: true
|
||||||
|
|
||||||
|
- id: vm122-docker-api
|
||||||
|
title: Docker container api
|
||||||
|
spec: "035"
|
||||||
|
kind: sw
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: docker
|
||||||
|
container: ligbox-ops-platform_api_1
|
||||||
|
expect_running: true
|
||||||
|
|
||||||
|
- vm: "123"
|
||||||
|
vm_label: "VM123 · Finance & Hosting"
|
||||||
|
ip: "10.10.10.123"
|
||||||
|
services:
|
||||||
|
- id: vm123-foss
|
||||||
|
title: FOSSBilling
|
||||||
|
spec: "024"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.123:8092/"
|
||||||
|
expect_status: [200, 301, 302, 403]
|
||||||
|
|
||||||
|
- id: vm123-odoo
|
||||||
|
title: Odoo 16
|
||||||
|
spec: "024"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.123:8069/web/login"
|
||||||
|
expect_status: [200, 301, 302]
|
||||||
|
|
||||||
|
- id: vm123-openpanel-ui
|
||||||
|
title: OpenPanel CE
|
||||||
|
spec: "028"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "https://openpanel.ligbox.com.br/"
|
||||||
|
expect_status: [200, 301, 302, 403]
|
||||||
|
|
||||||
|
- id: vm123-openadmin
|
||||||
|
title: OpenAdmin
|
||||||
|
spec: "024"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "https://admin.openpanel.ligbox.com.br/"
|
||||||
|
expect_status: [200, 301, 302, 403]
|
||||||
|
|
||||||
|
- id: vm123-openpanel-bridge
|
||||||
|
title: OpenPanel FOSS Bridge
|
||||||
|
spec: "028"
|
||||||
|
kind: api
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http_bearer
|
||||||
|
url: "http://10.10.10.123:18087/api"
|
||||||
|
env_token: OPENPANEL_BRIDGE_TOKEN
|
||||||
|
expect_status: [200, 401, 403]
|
||||||
|
|
||||||
|
- id: vm123-ops-console
|
||||||
|
title: Ops Console UI
|
||||||
|
spec: "019"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.123:8100/health"
|
||||||
|
expect_status: [200]
|
||||||
|
|
||||||
|
- id: vm123-phpmyadmin
|
||||||
|
title: phpMyAdmin
|
||||||
|
spec: "—"
|
||||||
|
kind: sw
|
||||||
|
tier: slow
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.123:8888/"
|
||||||
|
expect_status: [200, 301, 302, 403]
|
||||||
|
|
||||||
|
- id: vm123-ollama
|
||||||
|
title: Ollama LLM
|
||||||
|
spec: "029"
|
||||||
|
kind: api
|
||||||
|
tier: slow
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.123:11434/api/tags"
|
||||||
|
expect_status: [200]
|
||||||
|
|
||||||
|
- vm: "130"
|
||||||
|
vm_label: "CT130 · Spec Hub"
|
||||||
|
ip: "10.10.10.130"
|
||||||
|
services:
|
||||||
|
- id: vm130-forgejo
|
||||||
|
title: Forgejo Git
|
||||||
|
spec: "031"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.130:3000/"
|
||||||
|
expect_status: [200, 301, 302, 403]
|
||||||
|
|
||||||
|
- id: vm130-spec-portal
|
||||||
|
title: Spec Portal
|
||||||
|
spec: "031"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.130:8080/"
|
||||||
|
expect_status: [200]
|
||||||
|
|
||||||
|
- id: vm130-spec-public
|
||||||
|
title: Spec Hub (WAN)
|
||||||
|
spec: "031"
|
||||||
|
kind: integration
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "https://spec.ligbox.com.br/"
|
||||||
|
expect_status: [200, 301, 302]
|
||||||
|
|
||||||
|
# Fase 2 — descomentar quando activar
|
||||||
|
# - vm: "104"
|
||||||
|
# vm_label: "VM104 · Wazuh"
|
||||||
|
# services: [...]
|
||||||
|
|
@ -13,10 +13,13 @@ vm112:
|
||||||
|
|
||||||
vm122:
|
vm122:
|
||||||
tag: main
|
tag: main
|
||||||
description: Ops Desk API + worker + frontend
|
description: Ops Desk API + worker + frontend + Watchman (Spec 035)
|
||||||
project: projects/ops-desk
|
project: projects/ops-desk
|
||||||
paths:
|
paths:
|
||||||
- projects/ops-desk
|
- projects/ops-desk
|
||||||
|
- projects/integrations/watchman
|
||||||
|
- deploy/watchman
|
||||||
|
- specs/035-ligbox-watchman/contracts/stack-services.yaml
|
||||||
deploy_to: /opt/ligbox-ops-platform
|
deploy_to: /opt/ligbox-ops-platform
|
||||||
hub_mirror: /opt/ligbox-spec-hub/repos/ligbox-ops-platform/projects/ops-desk
|
hub_mirror: /opt/ligbox-spec-hub/repos/ligbox-ops-platform/projects/ops-desk
|
||||||
note: Symlinks api/worker/frontend na raiz do repo apontam para projects/ops-desk/
|
note: Symlinks api/worker/frontend na raiz do repo apontam para projects/ops-desk/
|
||||||
|
|
@ -41,6 +44,14 @@ vm104:
|
||||||
- projects/integrations/wazuh
|
- projects/integrations/wazuh
|
||||||
deploy_to: /opt/wazuh-integration
|
deploy_to: /opt/wazuh-integration
|
||||||
|
|
||||||
|
vm116:
|
||||||
|
tag: main
|
||||||
|
description: Nextcloud Hub — ampliar storage mail Carbonio (Spec 034)
|
||||||
|
paths:
|
||||||
|
- deploy/vm116-nextcloud
|
||||||
|
deploy_to: /opt/vm116-nextcloud
|
||||||
|
note: Nextcloud VM124 (10.10.10.124) — VM116=RustDesk; 2 vCPU 4GB 500GB
|
||||||
|
|
||||||
ct130:
|
ct130:
|
||||||
tag: main
|
tag: main
|
||||||
description: Spec Hub Git + Portal
|
description: Spec Hub Git + Portal
|
||||||
|
|
|
||||||
27
deploy/vm116-nextcloud/.env.example
Normal file
27
deploy/vm116-nextcloud/.env.example
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Spec 034 — Nextcloud Hub Ligbox
|
||||||
|
# cp .env.example .env && edit secrets before: docker compose up -d
|
||||||
|
|
||||||
|
# Public hostname (Traefik CT114 terminates TLS)
|
||||||
|
NC_DOMAIN=cloud.ligbox.com.br
|
||||||
|
NC_TRUSTED_DOMAINS=cloud.ligbox.com.br localhost
|
||||||
|
|
||||||
|
# Nextcloud admin (first install only)
|
||||||
|
NC_ADMIN_USER=ligbox-provisioner
|
||||||
|
NC_ADMIN_PASSWORD=change-me-strong
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
POSTGRES_DB=nextcloud
|
||||||
|
POSTGRES_USER=nextcloud
|
||||||
|
POSTGRES_PASSWORD=change-me-db
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=redis
|
||||||
|
|
||||||
|
# Default quota for new users (OCS / occ)
|
||||||
|
NC_DEFAULT_QUOTA=25GB
|
||||||
|
|
||||||
|
# Internal listen (Traefik CT114 → this port on LAN)
|
||||||
|
NC_HTTP_PORT=8080
|
||||||
|
|
||||||
|
# Timezone
|
||||||
|
TZ=America/Sao_Paulo
|
||||||
55
deploy/vm116-nextcloud/README.md
Normal file
55
deploy/vm116-nextcloud/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# deploy/vm116-nextcloud — Spec 034 Fase 0
|
||||||
|
|
||||||
|
Nextcloud Hub Ligbox: ampliar capacidade Files (warm tier) para tenants Carbonio VM112.
|
||||||
|
|
||||||
|
> Pasta `vm116-nextcloud` = nome da Spec 034. **Host alvo: VM124** (`10.10.10.124`).
|
||||||
|
|
||||||
|
## Alvo Roger (2026-06-21)
|
||||||
|
|
||||||
|
| Parâmetro | Valor |
|
||||||
|
|-----------|-------|
|
||||||
|
| **VMID** | **124** (primeira livre após VM123) |
|
||||||
|
| **IP LAN** | `10.10.10.124/24` |
|
||||||
|
| **SSH WAN** | `:2524` → `10.10.10.124:22` |
|
||||||
|
| **vCPU** | **2** |
|
||||||
|
| **RAM** | **4 GB** |
|
||||||
|
| **Disco** | **500 GB** (se `local-lvm` tiver espaço) |
|
||||||
|
|
||||||
|
| IP | Estado |
|
||||||
|
|----|--------|
|
||||||
|
| `10.10.10.116` | **RustDesk** — não usar |
|
||||||
|
| `10.10.10.118` | Não usar (Roger) |
|
||||||
|
| `10.10.10.124` | **Livre** — Nextcloud |
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Serviço | Imagem | RAM limit |
|
||||||
|
|---------|--------|-----------|
|
||||||
|
| Nextcloud 30 | `nextcloud:30-apache` | 1024m |
|
||||||
|
| PostgreSQL 16 | `postgres:16-alpine` | 384m |
|
||||||
|
| Redis 7 | `redis:7-alpine` | 64m |
|
||||||
|
| Cron | same as app | 128m |
|
||||||
|
|
||||||
|
Porta **8080** → Traefik CT114 (TLS externo).
|
||||||
|
|
||||||
|
## Deploy rápido
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Proxmox (no host PVE)
|
||||||
|
bash scripts/proxmox-create-vm.sh
|
||||||
|
|
||||||
|
# 2. Na VM124 (Ubuntu 24.04)
|
||||||
|
bash scripts/bootstrap.sh
|
||||||
|
cp .env.example .env # editar passwords
|
||||||
|
bash scripts/install.sh
|
||||||
|
|
||||||
|
# 3. CT114 Traefik — traefik/nextcloud-hub.yml.snippet
|
||||||
|
# DNS: cloud.ligbox.com.br → Traefik
|
||||||
|
|
||||||
|
curl -s http://10.10.10.124:8080/status.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
|
||||||
|
- [spec.md](../specs/034-nextcloud-carbonio-vm112-integration/spec.md)
|
||||||
|
- [VM124.md](../docs/vms/VM124.md)
|
||||||
71
deploy/vm116-nextcloud/docker-compose.yml
Normal file
71
deploy/vm116-nextcloud/docker-compose.yml
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Spec 034 — Nextcloud Hub (VM124)
|
||||||
|
# Traefik CT114 → http://10.10.10.116:8080 (or target IP)
|
||||||
|
# Usage: docker compose --env-file .env up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-nextcloud}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-nextcloud}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- nc-db:/var/lib/postgresql/data
|
||||||
|
command: postgres -c shared_buffers=128MB -c max_connections=80
|
||||||
|
mem_limit: 384m
|
||||||
|
networks: [nextcloud]
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --save 60 1000 --loglevel warning
|
||||||
|
volumes:
|
||||||
|
- nc-redis:/data
|
||||||
|
mem_limit: 64m
|
||||||
|
networks: [nextcloud]
|
||||||
|
|
||||||
|
nextcloud:
|
||||||
|
image: nextcloud:30-apache
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${NC_HTTP_PORT:-8080}:80"
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST: db
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-nextcloud}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-nextcloud}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
REDIS_HOST: redis
|
||||||
|
NEXTCLOUD_ADMIN_USER: ${NC_ADMIN_USER:-ligbox-provisioner}
|
||||||
|
NEXTCLOUD_ADMIN_PASSWORD: ${NC_ADMIN_PASSWORD:?set NC_ADMIN_PASSWORD}
|
||||||
|
NEXTCLOUD_TRUSTED_DOMAINS: ${NC_TRUSTED_DOMAINS:-cloud.ligbox.com.br}
|
||||||
|
APACHE_BODY_LIMIT: 1073741824
|
||||||
|
PHP_MEMORY_LIMIT: 384M
|
||||||
|
PHP_UPLOAD_LIMIT: 512M
|
||||||
|
volumes:
|
||||||
|
- nc-data:/var/www/html
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
mem_limit: 1024m
|
||||||
|
networks: [nextcloud]
|
||||||
|
|
||||||
|
cron:
|
||||||
|
image: nextcloud:30-apache
|
||||||
|
restart: unless-stopped
|
||||||
|
entrypoint: /cron.sh
|
||||||
|
volumes:
|
||||||
|
- nc-data:/var/www/html
|
||||||
|
depends_on:
|
||||||
|
- nextcloud
|
||||||
|
mem_limit: 128m
|
||||||
|
networks: [nextcloud]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nc-db:
|
||||||
|
nc-redis:
|
||||||
|
nc-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nextcloud:
|
||||||
|
driver: bridge
|
||||||
32
deploy/vm116-nextcloud/proxmox/vm124-user-data.yaml
Normal file
32
deploy/vm116-nextcloud/proxmox/vm124-user-data.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
#cloud-config
|
||||||
|
hostname: vm124-nextcloud-ligbox
|
||||||
|
manage_etc_hosts: true
|
||||||
|
ssh_pwauth: true
|
||||||
|
disable_root: false
|
||||||
|
chpasswd:
|
||||||
|
list: |
|
||||||
|
root:805353
|
||||||
|
admin:805353
|
||||||
|
mini:805353
|
||||||
|
expire: false
|
||||||
|
users:
|
||||||
|
- default
|
||||||
|
- name: admin
|
||||||
|
gecos: Ligbox Admin
|
||||||
|
groups: [sudo]
|
||||||
|
shell: /bin/bash
|
||||||
|
lock_passwd: false
|
||||||
|
- name: mini
|
||||||
|
gecos: Ligbox Mini
|
||||||
|
shell: /bin/bash
|
||||||
|
lock_passwd: false
|
||||||
|
package_update: true
|
||||||
|
packages:
|
||||||
|
- sudo
|
||||||
|
- curl
|
||||||
|
- ca-certificates
|
||||||
|
runcmd:
|
||||||
|
- mkdir -p /etc/ssh/sshd_config.d
|
||||||
|
- echo 'PasswordAuthentication yes' > /etc/ssh/sshd_config.d/60-ligbox.conf
|
||||||
|
- systemctl restart ssh || systemctl restart sshd
|
||||||
|
- hostnamectl set-hostname vm124-nextcloud-ligbox
|
||||||
21
deploy/vm116-nextcloud/scripts/backup-occ.sh
Executable file
21
deploy/vm116-nextcloud/scripts/backup-occ.sh
Executable file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Daily backup — Nextcloud data + PostgreSQL dump (Spec 034)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STACK_DIR="${STACK_DIR:-/opt/vm116-nextcloud}"
|
||||||
|
BACKUP_ROOT="${BACKUP_ROOT:-/var/backups/nextcloud}"
|
||||||
|
STAMP=$(date +%Y%m%d-%H%M)
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_ROOT/$STAMP"
|
||||||
|
cd "$STACK_DIR"
|
||||||
|
|
||||||
|
docker compose exec -T db pg_dump -U nextcloud nextcloud | gzip > "$BACKUP_ROOT/$STAMP/postgres.sql.gz"
|
||||||
|
docker compose exec -T -u www-data nextcloud occ maintenance:mode --on
|
||||||
|
docker run --rm \
|
||||||
|
-v vm116-nextcloud_nc-data:/data:ro \
|
||||||
|
-v "$BACKUP_ROOT/$STAMP":/backup \
|
||||||
|
alpine tar czf /backup/nc-data.tar.gz -C /data .
|
||||||
|
docker compose exec -T -u www-data nextcloud occ maintenance:mode --off
|
||||||
|
|
||||||
|
find "$BACKUP_ROOT" -maxdepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
echo "Backup: $BACKUP_ROOT/$STAMP"
|
||||||
68
deploy/vm116-nextcloud/scripts/bootstrap.sh
Executable file
68
deploy/vm116-nextcloud/scripts/bootstrap.sh
Executable file
|
|
@ -0,0 +1,68 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# VM124 bootstrap — Docker, users, firewall (Spec 034 Fase 0)
|
||||||
|
# Run as root on fresh Ubuntu 24.04 VM (2 vCPU · 4 GB RAM).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DESK_PASSWORD="${DESK_PASSWORD:-805353}"
|
||||||
|
HOSTNAME="${HOSTNAME:-vm124-nextcloud-ligbox}"
|
||||||
|
|
||||||
|
echo "==> Users mini, admin, root"
|
||||||
|
id mini &>/dev/null || useradd -m -s /bin/bash mini
|
||||||
|
id admin &>/dev/null || useradd -m -s /bin/bash admin
|
||||||
|
usermod -aG sudo admin 2>/dev/null || true
|
||||||
|
echo "mini:${DESK_PASSWORD}" | chpasswd
|
||||||
|
echo "admin:${DESK_PASSWORD}" | chpasswd
|
||||||
|
echo "root:${DESK_PASSWORD}" | chpasswd
|
||||||
|
|
||||||
|
echo "==> Swap 2G (4 GB RAM)"
|
||||||
|
if ! swapon --show | grep -q swapfile; then
|
||||||
|
fallocate -l 2G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=2048
|
||||||
|
chmod 600 /swapfile
|
||||||
|
mkswap /swapfile
|
||||||
|
swapon /swapfile
|
||||||
|
grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Base packages"
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq curl wget git ufw fail2ban unattended-upgrades \
|
||||||
|
apt-transport-https ca-certificates gnupg lsb-release jq
|
||||||
|
|
||||||
|
echo "==> Docker"
|
||||||
|
if ! command -v docker &>/dev/null; then
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||||
|
fi
|
||||||
|
usermod -aG docker admin 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "==> fail2ban sshd"
|
||||||
|
cat > /etc/fail2ban/jail.local <<'EOF'
|
||||||
|
[sshd]
|
||||||
|
enabled = true
|
||||||
|
port = ssh
|
||||||
|
filter = sshd
|
||||||
|
logpath = /var/log/auth.log
|
||||||
|
maxretry = 5
|
||||||
|
bantime = 3600
|
||||||
|
findtime = 600
|
||||||
|
EOF
|
||||||
|
systemctl enable fail2ban
|
||||||
|
systemctl restart fail2ban
|
||||||
|
|
||||||
|
echo "==> SSH password auth (cloud-init override)"
|
||||||
|
mkdir -p /etc/ssh/sshd_config.d
|
||||||
|
echo 'PasswordAuthentication yes' > /etc/ssh/sshd_config.d/60-cloudimg-settings.conf
|
||||||
|
systemctl restart ssh
|
||||||
|
|
||||||
|
echo "==> UFW"
|
||||||
|
ufw allow OpenSSH
|
||||||
|
ufw allow 8080/tcp comment 'Nextcloud HTTP (Traefik backend)'
|
||||||
|
ufw --force enable || true
|
||||||
|
|
||||||
|
hostnamectl set-hostname "$HOSTNAME" 2>/dev/null || true
|
||||||
|
echo "Bootstrap concluído: $HOSTNAME"
|
||||||
35
deploy/vm116-nextcloud/scripts/install.sh
Executable file
35
deploy/vm116-nextcloud/scripts/install.sh
Executable file
|
|
@ -0,0 +1,35 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install Nextcloud stack on target VM (Spec 034)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STACK_DIR="${STACK_DIR:-/opt/vm116-nextcloud}"
|
||||||
|
REPO_SRC="${REPO_SRC:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||||
|
|
||||||
|
if [[ ! -f "$REPO_SRC/docker-compose.yml" ]]; then
|
||||||
|
echo "docker-compose.yml not found in $REPO_SRC" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$STACK_DIR"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude '.env' \
|
||||||
|
"$REPO_SRC/" "$STACK_DIR/"
|
||||||
|
|
||||||
|
cd "$STACK_DIR"
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "Created $STACK_DIR/.env — edit NC_ADMIN_PASSWORD and POSTGRES_PASSWORD before continuing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker compose --env-file .env pull
|
||||||
|
docker compose --env-file .env up -d
|
||||||
|
|
||||||
|
sleep 15
|
||||||
|
docker compose exec -T -u www-data nextcloud occ status
|
||||||
|
docker compose exec -T -u www-data nextcloud occ config:system:set overwritehost --value="${NC_DOMAIN:-cloud.ligbox.com.br}"
|
||||||
|
docker compose exec -T -u www-data nextcloud occ config:system:set overwriteprotocol --value="https"
|
||||||
|
docker compose exec -T -u www-data nextcloud occ config:system:set trusted_proxies 0 --value="10.10.10.114"
|
||||||
|
docker compose exec -T -u www-data nextcloud occ app:enable provisioning_api 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Nextcloud OK — http://127.0.0.1:${NC_HTTP_PORT:-8080}/status.php"
|
||||||
34
deploy/vm116-nextcloud/scripts/occ-provision-user.sh
Executable file
34
deploy/vm116-nextcloud/scripts/occ-provision-user.sh
Executable file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Provision Nextcloud user via occ (wizard VM112 calls this or OCS API)
|
||||||
|
# Usage: occ-provision-user.sh admin@dominio.com.br "Display Name" 25GB [password]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STACK_DIR="${STACK_DIR:-/opt/vm116-nextcloud}"
|
||||||
|
USERID="${1:?userid (email)}"
|
||||||
|
DISPLAY="${2:-$USERID}"
|
||||||
|
QUOTA="${3:-25GB}"
|
||||||
|
PASSWORD="${4:-}"
|
||||||
|
|
||||||
|
cd "$STACK_DIR"
|
||||||
|
[[ -f .env ]] && set -a && source .env && set +a
|
||||||
|
|
||||||
|
if docker compose exec -T -u www-data nextcloud occ user:info "$USERID" &>/dev/null; then
|
||||||
|
echo "User $USERID already exists"
|
||||||
|
docker compose exec -T -u www-data nextcloud occ user:setting "$USERID" files quota "$QUOTA"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$PASSWORD" ]]; then
|
||||||
|
docker compose exec -T -u www-data nextcloud occ user:add \
|
||||||
|
--display-name="$DISPLAY" \
|
||||||
|
--password-from-env \
|
||||||
|
"$USERID" <<< "OC_PASS=$PASSWORD"
|
||||||
|
else
|
||||||
|
docker compose exec -T -u www-data nextcloud occ user:add \
|
||||||
|
--display-name="$DISPLAY" \
|
||||||
|
--generate-password \
|
||||||
|
"$USERID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker compose exec -T -u www-data nextcloud occ user:setting "$USERID" files quota "$QUOTA"
|
||||||
|
echo "Provisioned: $USERID quota=$QUOTA"
|
||||||
83
deploy/vm116-nextcloud/scripts/proxmox-create-vm.sh
Executable file
83
deploy/vm116-nextcloud/scripts/proxmox-create-vm.sh
Executable file
|
|
@ -0,0 +1,83 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create Nextcloud VM on Proxmox — run ON PVE host as root (Spec 034 Fase 0)
|
||||||
|
#
|
||||||
|
# Alvo Roger 2026-06-21: VM124 (após VM123) — NÃO usar VM118 nem VM116 (RustDesk).
|
||||||
|
# Recursos: 2 vCPU · 4 GB RAM · 500 GB disco (se storage local-lvm tiver espaço).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VMID="${VMID:-124}"
|
||||||
|
NAME="${NAME:-vm124-nextcloud-ligbox}"
|
||||||
|
CORES="${CORES:-2}"
|
||||||
|
MEMORY="${MEMORY:-4096}"
|
||||||
|
DISK_GB="${DISK_GB:-500}"
|
||||||
|
BRIDGE="${BRIDGE:-vmbr0}"
|
||||||
|
STORAGE="${PVE_STORAGE:-local-lvm}"
|
||||||
|
ISO_STORAGE="${ISO_STORAGE:-local}"
|
||||||
|
UBUNTU_ISO="${UBUNTU_ISO:-ubuntu-24.04-live-server-amd64.iso}"
|
||||||
|
IP="${IP:-10.10.10.124/24}"
|
||||||
|
GW="${GW:-10.10.10.1}"
|
||||||
|
SSH_WAN_PORT="${SSH_WAN_PORT:-2524}"
|
||||||
|
|
||||||
|
if ! command -v qm &>/dev/null; then
|
||||||
|
echo "Execute no host Proxmox." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Storage disponível (${STORAGE}):"
|
||||||
|
pvesm status 2>/dev/null | grep -E "Name|${STORAGE}" || true
|
||||||
|
|
||||||
|
AVAIL_GB=$(pvesm status 2>/dev/null | awk -v s="$STORAGE" '$1==s {print int($7)}')
|
||||||
|
if [[ -n "${AVAIL_GB:-}" && "${AVAIL_GB}" -lt "${DISK_GB}" ]]; then
|
||||||
|
echo "AVISO: ${STORAGE} tem ~${AVAIL_GB} GB livres; pedido ${DISK_GB} GB." >&2
|
||||||
|
echo "Ajuste DISK_GB ou PVE_STORAGE antes de continuar." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if qm status "$VMID" &>/dev/null; then
|
||||||
|
echo "VM $VMID já existe."
|
||||||
|
qm config "$VMID"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
qm create "$VMID" \
|
||||||
|
--name "$NAME" \
|
||||||
|
--cores "$CORES" \
|
||||||
|
--memory "$MEMORY" \
|
||||||
|
--net0 "virtio,bridge=${BRIDGE}" \
|
||||||
|
--scsihw virtio-scsi-pci \
|
||||||
|
--scsi0 "${STORAGE}:${DISK_GB}" \
|
||||||
|
--ostype l26 \
|
||||||
|
--agent enabled=1 \
|
||||||
|
--boot order=scsi0
|
||||||
|
|
||||||
|
if pvesm list "${ISO_STORAGE}" 2>/dev/null | grep -q "$UBUNTU_ISO"; then
|
||||||
|
qm set "$VMID" --ide2 "${ISO_STORAGE}:iso/${UBUNTU_ISO},media=cdrom"
|
||||||
|
qm set "$VMID" --boot order=ide2\;scsi0
|
||||||
|
echo "ISO anexada."
|
||||||
|
else
|
||||||
|
echo "ISO $UBUNTU_ISO não encontrada — instalar Ubuntu 24.04 manualmente."
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
VM $VMID criada ($NAME).
|
||||||
|
Recursos: ${CORES} vCPU, ${MEMORY} MB RAM, ${DISK_GB} GB disco
|
||||||
|
|
||||||
|
Após Ubuntu 24.04:
|
||||||
|
IP estático: ${IP}
|
||||||
|
Gateway: ${GW}
|
||||||
|
DNS: 1.1.1.1
|
||||||
|
|
||||||
|
Deploy stack:
|
||||||
|
scp -r deploy/vm116-nextcloud root@${IP%/*}:/opt/
|
||||||
|
ssh root@${IP%/*} bash /opt/vm116-nextcloud/scripts/bootstrap.sh
|
||||||
|
cd /opt/vm116-nextcloud && cp .env.example .env && nano .env
|
||||||
|
bash /opt/vm116-nextcloud/scripts/install.sh
|
||||||
|
|
||||||
|
SSH WAN: configurar pfSense DNAT porta ${SSH_WAN_PORT} → ${IP%/*}:22
|
||||||
|
|
||||||
|
Traefik CT114: cloud.ligbox.com.br → http://${IP%/*}:8080
|
||||||
|
|
||||||
|
NOTA: VM116 = RustDesk (não tocar). Nextcloud = VM124 apenas.
|
||||||
|
|
||||||
|
EOF
|
||||||
33
deploy/vm116-nextcloud/traefik/nextcloud-hub.yml.snippet
Normal file
33
deploy/vm116-nextcloud/traefik/nextcloud-hub.yml.snippet
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Traefik CT114 — snippet for Nextcloud Hub (Spec 034)
|
||||||
|
# Copy to CT114 dynamic config and reload Traefik.
|
||||||
|
# Backend: Nextcloud on LAN :8080 (TLS terminated here)
|
||||||
|
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
nextcloud-hub:
|
||||||
|
rule: Host(`cloud.ligbox.com.br`)
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
service: nextcloud-hub-svc
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
# Per-tenant Files — add via file provider or certbot SAN (Spec 017 pattern)
|
||||||
|
# nextcloud-files-tenant:
|
||||||
|
# rule: Host(`files.example.com.br`)
|
||||||
|
# service: nextcloud-hub-svc
|
||||||
|
# tls: ...
|
||||||
|
|
||||||
|
services:
|
||||||
|
nextcloud-hub-svc:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://10.10.10.124:8080"
|
||||||
|
# VM124 Nextcloud Hub (Spec 034)
|
||||||
|
passHostHeader: true
|
||||||
|
|
||||||
|
middlewares:
|
||||||
|
nextcloud-headers:
|
||||||
|
headers:
|
||||||
|
customRequestHeaders:
|
||||||
|
X-Forwarded-Proto: "https"
|
||||||
|
|
@ -172,3 +172,19 @@ iptables -t nat -L PREROUTING -n | sort | uniq -c | sort -rn | head
|
||||||
- Inventário completo: `obsidian-infra/docs/TABELA_VMS_INVENTARIO_ATUALIZADA_2026-03-30.md`
|
- Inventário completo: `obsidian-infra/docs/TABELA_VMS_INVENTARIO_ATUALIZADA_2026-03-30.md`
|
||||||
- Segurança Hetzner: `obsidian-infra/docs/VERIFICACAO_ROLES_SEGURANCA_HETZNER.md`
|
- Segurança Hetzner: `obsidian-infra/docs/VERIFICACAO_ROLES_SEGURANCA_HETZNER.md`
|
||||||
- Constitution: `.specify/memory/constitution.md`
|
- Constitution: `.specify/memory/constitution.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DNS53 público — VM123 BIND (2026-06-22)
|
||||||
|
|
||||||
|
**IP público:** `95.216.14.162:53` · **Backend:** `10.10.10.123:53`
|
||||||
|
|
||||||
|
Documentação completa:
|
||||||
|
- `ORIENTACAO_DNS53_PROXMOX_20260622.md`
|
||||||
|
- `../../exec-reports/EXEC_DNS53_PROXMOX_20260622_v3_FINAL.md`
|
||||||
|
|
||||||
|
**Solução activa:** DNAT vmbr0 → VM123 + SNAT ida `10.10.10.254` (conntrack retorno `95.216.14.162`).
|
||||||
|
|
||||||
|
**Não usar:** DNAT `10.0.0.2`, `MASQUERADE -o vmbr4000`, `MASQUERADE -d 10.10.10.123:53`.
|
||||||
|
|
||||||
|
**Drift vmbr2:** pfSense WAN real = `95.216.14.146` em vmbr0; link `10.0.0.2` (vmbr2) inactivo.
|
||||||
|
|
|
||||||
127
docs/network/ORIENTACAO_DNS53_PROXMOX_20260622.md
Normal file
127
docs/network/ORIENTACAO_DNS53_PROXMOX_20260622.md
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
# Orientação DNS53 — Proxmox → VM123 BIND
|
||||||
|
|
||||||
|
**Versão:** 3.0 FINAL
|
||||||
|
**Data:** 2026-06-22
|
||||||
|
**Host:** Proxmox `big1` · IP público `95.216.14.162`
|
||||||
|
**Backend:** VM123 `10.10.10.123:53` (container `openpanel_dns` / BIND9)
|
||||||
|
**Status:** ✅ Operacional
|
||||||
|
**Exec report:** `exec-reports/EXEC_DNS53_PROXMOX_20260622_v3_FINAL.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumo executivo
|
||||||
|
|
||||||
|
O DNS público `95.216.14.162:53` é servido pelo BIND na VM123. O Proxmox faz **DNAT** da porta 53 e **SNAT de ida** para `10.10.10.254`, permitindo que o conntrack devolva respostas com origem `95.216.14.162`. Sem o SNAT de ida, o pfSense (WAN `95.216.14.146`) intercepta o retorno e o cliente externo recebe timeout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
Cliente Internet │ Proxmox big1 │
|
||||||
|
│ │ vmbr0: 95.216.14.162 │
|
||||||
|
│ │ │ PREROUTING DNAT :53 │
|
||||||
|
└──────────►│ ▼ │
|
||||||
|
│ vmbr4000: 10.10.10.254 (SNAT ida) │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ VM123: 10.10.10.123:53 (BIND) │
|
||||||
|
│ │ resposta → 10.10.10.254 │
|
||||||
|
│ ▼ │
|
||||||
|
│ reverse NAT → src 95.216.14.162 │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Cliente ✅
|
||||||
|
|
||||||
|
pfSense VM100: WAN 95.216.14.146 · LAN 10.10.10.1
|
||||||
|
(fora do caminho de retorno DNS público — não usar para SNAT de 162)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuração activa
|
||||||
|
|
||||||
|
### Bloco `/etc/network/interfaces` (Proxmox)
|
||||||
|
|
||||||
|
Ver linhas 120–136 — bloco `# DNS53 ligbox — 2026-06-22`.
|
||||||
|
|
||||||
|
Componentes:
|
||||||
|
1. **PREROUTING** `-d 95.216.14.162 --dport 53` → `10.10.10.123:53`
|
||||||
|
2. **FORWARD** ACCEPT dport/sport 53 para VM123
|
||||||
|
3. **POSTROUTING** SNAT `--to-source 10.10.10.254` para tráfego externo → VM123:53
|
||||||
|
|
||||||
|
### `/etc/iptables/rules.v4`
|
||||||
|
|
||||||
|
Manter comentado (não reactivar):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#-A POSTROUTING -o vmbr4000 -j MASQUERADE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O que NÃO fazer
|
||||||
|
|
||||||
|
| Acção | Risco |
|
||||||
|
|-------|-------|
|
||||||
|
| DNAT para `10.0.0.2:53` | Timeout — WAN vmbr2 inactiva |
|
||||||
|
| DNAT para `10.10.10.1:53` | Unbound LAN — não é BIND público |
|
||||||
|
| DNAT para `95.216.14.146:53` | Resposta sai como 146, não 162 |
|
||||||
|
| `MASQUERADE -o vmbr4000` | Quebra retorno DNS |
|
||||||
|
| `MASQUERADE -d 10.10.10.123:53` | Quebra conntrack |
|
||||||
|
| Reactivar `10.0.0.2` pfSense WAN | Parte pfSense |
|
||||||
|
| `ifreload -a` sem rever DNS53 | Pode restaurar regras erradas |
|
||||||
|
| Testar com `google.com` | REFUSED — servidor autoritativo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testes de validação
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# LAN (VM112 ou qualquer host 10.10.10.0/24)
|
||||||
|
dig @95.216.14.162 betinplace.com MX +short
|
||||||
|
dig @95.216.14.162 diarissima.com MX +short
|
||||||
|
|
||||||
|
# BIND directo
|
||||||
|
dig @10.10.10.123 betinplace.com MX +short
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
nslookup betinplace.com 95.216.14.162
|
||||||
|
nslookup -type=mx diarissima.com 95.216.14.162
|
||||||
|
|
||||||
|
# Contadores
|
||||||
|
iptables -t nat -L POSTROUTING -n -v | grep "10.10.10.123"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pendências operacionais
|
||||||
|
|
||||||
|
1. **Registrador betinplace.com:** NS → `ns1.ligbox.com.br`, `ns2.ligbox.com.br`
|
||||||
|
2. Glue Cloudflare `ns1/ns2.ligbox.com.br` → `95.216.14.162` (já OK)
|
||||||
|
3. Limpeza opcional: port forwards pfSense IDs 63–66
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Histórico de versões
|
||||||
|
|
||||||
|
| Versão | Data | Notas |
|
||||||
|
|--------|------|-------|
|
||||||
|
| 1.0 | 2026-06-22 | DNAT → 10.0.0.2 (falhou) |
|
||||||
|
| 2.0 | 2026-06-22 | DNAT directo + MASQUERADE (timeout) |
|
||||||
|
| 3.0 FINAL | 2026-06-22 | DNAT + SNAT ida 10.10.10.254 ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referências cruzadas
|
||||||
|
|
||||||
|
- `exec-reports/EXEC_DNS53_PROXMOX_20260622_v3_FINAL.md`
|
||||||
|
- `docs/network/INTERFACES_PROXMOX.md`
|
||||||
|
- `docs/network/VM123_INVENTARIO.md`
|
||||||
|
- `specs/037-dns-multi-cloudflare-orchestration/spec.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Ligbox Ops Platform · CT130 spec-hub*
|
||||||
|
|
@ -1,32 +1,17 @@
|
||||||
# VM116 — Nextcloud Hub (Spec 034)
|
# VM116 / CT116 — RustDesk (remoto)
|
||||||
|
|
||||||
| Item | Valor |
|
| Item | Valor |
|
||||||
|------|-------|
|
|------|-------|
|
||||||
| **IP LAN** | `10.10.10.116` (proposto) |
|
| **IP LAN** | `10.10.10.116/24` |
|
||||||
| **SSH WAN** | `95.216.14.146:2516` (reservar pfSense) |
|
| **SSH WAN** | `95.216.14.146:2516` |
|
||||||
| **Hostname** | `cloud.ligbox.com.br` |
|
| **Hostname** | `rustdesk-server` |
|
||||||
| **URLs tenant** | `files.{dominio}` |
|
| **Tipo** | LXC (~10 GB disco · 2 GB RAM) |
|
||||||
|
|
||||||
## Papel
|
## Papel actual
|
||||||
|
|
||||||
- Armazenamento **Files** ampliado para tenants Ligbox Mail (VM112)
|
- **RustDesk** relay (`hbbs` / `hbbr`)
|
||||||
- App **Mail** opcional → IMAP/SMTP Carbonio VM112
|
- Portal remoto `ligit-remote-portal` (nginx)
|
||||||
- MinIO S3 (fase 2) — volume secundário Carbonio
|
|
||||||
|
|
||||||
## No repo Git (CT130)
|
## Spec 034
|
||||||
|
|
||||||
```
|
A Spec 034 documentava VM116 para Nextcloud — **decisão Roger 2026-06-21:** Nextcloud vai para **VM124** (`10.10.10.124`).
|
||||||
deploy/vm116-nextcloud/ # (a criar — Fase 0)
|
|
||||||
specs/034-nextcloud-carbonio-vm112-integration/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integração
|
|
||||||
|
|
||||||
- **← VM112:** Wizard provisiona users OCS após conta Carbonio
|
|
||||||
- **→ VM112:** Mail app IMAP `:993` · SMTP `:587`
|
|
||||||
- **→ CT114:** Traefik `files.{dom}` + `cloud.ligbox.com.br`
|
|
||||||
- **← Desk VM122:** health, status quota, purge users (Spec 017 ext.)
|
|
||||||
|
|
||||||
## Estado
|
|
||||||
|
|
||||||
📋 **Não provisionada** — Spec 034 Fase 0 pendente aprovação Roger.
|
|
||||||
|
|
|
||||||
37
docs/vms/VM124.md
Normal file
37
docs/vms/VM124.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# VM124 — Nextcloud Hub (Spec 034)
|
||||||
|
|
||||||
|
| Item | Valor |
|
||||||
|
|------|-------|
|
||||||
|
| **IP LAN** | `10.10.10.124/24` |
|
||||||
|
| **SSH WAN** | `95.216.14.146:2524` (reservar pfSense) |
|
||||||
|
| **Hostname** | `vm124-nextcloud-ligbox` · URL `cloud.ligbox.com.br` |
|
||||||
|
| **URLs tenant** | `files.{dominio}` |
|
||||||
|
| **Recursos** | 2 vCPU · **4 GB RAM** · **500 GB** disco |
|
||||||
|
|
||||||
|
## Papel
|
||||||
|
|
||||||
|
- Armazenamento **Files** ampliado para tenants Ligbox Mail (VM112)
|
||||||
|
- App **Mail** opcional → IMAP/SMTP Carbonio VM112
|
||||||
|
- MinIO S3 (fase 2) — volume secundário Carbonio
|
||||||
|
|
||||||
|
## No repo Git (CT130)
|
||||||
|
|
||||||
|
```
|
||||||
|
deploy/vm116-nextcloud/ # stack IaC (nome histórico Spec 034)
|
||||||
|
specs/034-nextcloud-carbonio-vm112-integration/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integração
|
||||||
|
|
||||||
|
- **← VM112:** Wizard provisiona users OCS após conta Carbonio
|
||||||
|
- **→ VM112:** Mail app IMAP `:993` · SMTP `:587`
|
||||||
|
- **→ CT114:** Traefik `files.{dom}` + `cloud.ligbox.com.br` → `:8080`
|
||||||
|
- **← Desk VM122:** health, status quota, purge users (Spec 017 ext.)
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
|
||||||
|
📋 **Não provisionada** — IP `10.10.10.124` livre (2026-06-21). Criar via `deploy/vm116-nextcloud/scripts/proxmox-create-vm.sh`.
|
||||||
|
|
||||||
|
## Nota VM116
|
||||||
|
|
||||||
|
IP `10.10.10.116` = **RustDesk** (LXC) — **não** usar para Nextcloud.
|
||||||
60
docs/vms/manifest.yaml
Normal file
60
docs/vms/manifest.yaml
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Deploy manifest — Ligbox Ops Platform
|
||||||
|
# Fonte: CT130 Forgejo · git pull por VM
|
||||||
|
|
||||||
|
vm112:
|
||||||
|
tag: main
|
||||||
|
description: Wizard onboard + Carbonio mail
|
||||||
|
project: projects/wizard
|
||||||
|
paths:
|
||||||
|
- projects/wizard/deploy/vm112-spec022
|
||||||
|
- projects/wizard/deploy/vm112-wizard-security
|
||||||
|
deploy_to:
|
||||||
|
- /opt/ligbox-wizard-scripts
|
||||||
|
|
||||||
|
vm122:
|
||||||
|
tag: main
|
||||||
|
description: Ops Desk API + worker + frontend + Watchman (Spec 035)
|
||||||
|
project: projects/ops-desk
|
||||||
|
paths:
|
||||||
|
- projects/ops-desk
|
||||||
|
- projects/integrations/watchman
|
||||||
|
- deploy/watchman
|
||||||
|
- specs/035-ligbox-watchman/contracts/stack-services.yaml
|
||||||
|
deploy_to: /opt/ligbox-ops-platform
|
||||||
|
hub_mirror: /opt/ligbox-spec-hub/repos/ligbox-ops-platform/projects/ops-desk
|
||||||
|
note: Symlinks api/worker/frontend na raiz do repo apontam para projects/ops-desk/
|
||||||
|
|
||||||
|
vm123:
|
||||||
|
tag: main
|
||||||
|
description: Finance stack + console
|
||||||
|
project: projects/finance
|
||||||
|
paths:
|
||||||
|
- projects/finance/deploy/vm123-finance-stack
|
||||||
|
- projects/finance/app/vm123
|
||||||
|
- specs/019-ops-console-active-operations/deploy
|
||||||
|
deploy_to:
|
||||||
|
- /opt/vm123-finance-stack
|
||||||
|
- /opt/ligbox-ops-console
|
||||||
|
|
||||||
|
vm104:
|
||||||
|
tag: main
|
||||||
|
description: Wazuh SIEM integration
|
||||||
|
project: projects/integrations
|
||||||
|
paths:
|
||||||
|
- projects/integrations/wazuh
|
||||||
|
deploy_to: /opt/wazuh-integration
|
||||||
|
|
||||||
|
vm116:
|
||||||
|
tag: main
|
||||||
|
description: Nextcloud Hub — ampliar storage mail Carbonio (Spec 034)
|
||||||
|
paths:
|
||||||
|
- deploy/vm116-nextcloud
|
||||||
|
deploy_to: /opt/vm116-nextcloud
|
||||||
|
note: Nextcloud VM124 (10.10.10.124) — VM116=RustDesk; 2 vCPU 4GB 500GB
|
||||||
|
|
||||||
|
ct130:
|
||||||
|
tag: main
|
||||||
|
description: Spec Hub Git + Portal
|
||||||
|
paths:
|
||||||
|
- .
|
||||||
|
deploy_to: /opt/ligbox-spec-hub
|
||||||
7
projects/cloudflare-agents/README.md
Normal file
7
projects/cloudflare-agents/README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Cloudflare Agents — Ligbox
|
||||||
|
|
||||||
|
| Projeto | Spec | Fase |
|
||||||
|
|---------|------|------|
|
||||||
|
| [onboard/](./onboard/) | 037 DNS multi-conta + onboarding | B, C, D |
|
||||||
|
|
||||||
|
VM112 (`projects/wizard/`) permanece **Fase A** — fonte de verdade APIs.
|
||||||
31
projects/cloudflare-agents/deploy.sh
Normal file
31
projects/cloudflare-agents/deploy.sh
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Deploy ligbox-onboard-agents — Spec 037 Fase B
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
ROOT="$SCRIPT_DIR/onboard"
|
||||||
|
TOKEN_FILE="$ROOT/secrets/cloudflare-wrangler.token"
|
||||||
|
|
||||||
|
if [[ ! -f "$TOKEN_FILE" ]]; then
|
||||||
|
echo "Crie $TOKEN_FILE com o API token (uma linha, cfat_...)"
|
||||||
|
echo "Dashboard: Manage Account > API Tokens > ligbox-wrangler-onboard-agents"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export CLOUDFLARE_API_TOKEN
|
||||||
|
CLOUDFLARE_API_TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
|
||||||
|
|
||||||
|
echo "Verificando token..."
|
||||||
|
curl -sf "https://api.cloudflare.com/client/v4/user/tokens/verify" \
|
||||||
|
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | grep -q '"success":true' \
|
||||||
|
|| { echo "Token inválido ou sem permissão verify"; exit 1; }
|
||||||
|
echo "Token OK"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
if [[ ! -d node_modules ]]; then
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
npm run deploy
|
||||||
|
echo ""
|
||||||
|
echo "Deploy concluído. Teste:"
|
||||||
|
echo " curl https://ligbox-onboard-agents.<subdominio-workers>.workers.dev/health"
|
||||||
|
echo "Depois configure rota agents.ligbox.com.br no dashboard ou wrangler.toml"
|
||||||
48
projects/cloudflare-agents/onboard/README.md
Normal file
48
projects/cloudflare-agents/onboard/README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Ligbox Onboard Agents — Cloudflare Workers (Spec 037 B/C/D)
|
||||||
|
|
||||||
|
Orquestração edge do onboarding DNS/CF/proj_*.
|
||||||
|
|
||||||
|
## Fases
|
||||||
|
|
||||||
|
| Fase | Conteúdo |
|
||||||
|
|------|----------|
|
||||||
|
| A | VM112 FastAPI (fonte de verdade) |
|
||||||
|
| B | `OnboardMaestro` Durable Object |
|
||||||
|
| C | `ProjMailAgent`, `CfZoneAgent`, `CfHandoffAgent` |
|
||||||
|
| D | handoff + provision-client-account via VM112 |
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd projects/cloudflare-agents/onboard
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
**Não commitar o token.** Guarde em `secrets/cloudflare-wrangler.token` (uma linha `cfat_...`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd projects/cloudflare-agents
|
||||||
|
chmod +x deploy.sh
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`account_id` ibytera já está em `wrangler.toml`. Após deploy: URL `*.workers.dev/health`.
|
||||||
|
Rota opcional: `agents.ligbox.com.br` na zona `ligbox.com.br`.
|
||||||
|
|
||||||
|
## API edge (Fase B)
|
||||||
|
|
||||||
|
| Método | Path | Descrição |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| GET | `/health` | Status |
|
||||||
|
| POST | `/api/session/{id}/run/proj-mail` | Cria proj_* via VM112 |
|
||||||
|
| POST | `/api/session/{id}/run/cf-zone` | provision-zone + apply |
|
||||||
|
| POST | `/api/session/{id}/human/ack` | Human-in-the-loop |
|
||||||
|
| POST | `/api/session/{id}/run/handoff` | Handoff gestor |
|
||||||
|
| GET | `/api/session/{id}/state` | Estado DO |
|
||||||
|
|
||||||
|
## VM112
|
||||||
|
|
||||||
|
Toda mutação real passa por `https://onboard.ligbox.com.br/api/onboarding/...`
|
||||||
18
projects/cloudflare-agents/onboard/package.json
Normal file
18
projects/cloudflare-agents/onboard/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "ligbox-onboard-agents",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"agents": "^0.0.47"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250214.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"wrangler": "^3.109.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
projects/cloudflare-agents/onboard/secrets/.gitignore
vendored
Normal file
6
projects/cloudflare-agents/onboard/secrets/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# NÃO commitar — wrangler deploy token (Workers, não DNS)
|
||||||
|
# Copiar token cfat_... para:
|
||||||
|
# secrets/cloudflare-wrangler.token
|
||||||
|
# Uma linha, sem aspas.
|
||||||
|
|
||||||
|
cloudflare-wrangler.token
|
||||||
12
projects/cloudflare-agents/onboard/secrets/README.txt
Normal file
12
projects/cloudflare-agents/onboard/secrets/README.txt
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Secrets — Workers Wrangler (NÃO commitar)
|
||||||
|
|
||||||
|
Ficheiro: `cloudflare-wrangler.token`
|
||||||
|
|
||||||
|
1. Abra o ficheiro no editor
|
||||||
|
2. Apague os comentários (#)
|
||||||
|
3. Cole UMA linha com o token `cfat_...` do dashboard Cloudflare
|
||||||
|
4. Guarde
|
||||||
|
5. `chmod 600 cloudflare-wrangler.token`
|
||||||
|
6. `cd ../../.. && ./deploy.sh`
|
||||||
|
|
||||||
|
Token criado em: Manage Account → API Tokens → ligbox-wrangler-onboard-agents
|
||||||
60
projects/cloudflare-agents/onboard/src/index.ts
Normal file
60
projects/cloudflare-agents/onboard/src/index.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/**
|
||||||
|
* Ligbox Onboard Agents — entry (Fase B/C/D edge)
|
||||||
|
* Spec 037 · Roger
|
||||||
|
*/
|
||||||
|
import { OnboardMaestro, ProjMailAgent, CfZoneAgent, CfHandoffAgent } from "./onboard-maestro";
|
||||||
|
|
||||||
|
export { OnboardMaestro, ProjMailAgent, CfZoneAgent, CfHandoffAgent };
|
||||||
|
|
||||||
|
export interface Env {
|
||||||
|
VM112_API_BASE: string;
|
||||||
|
ONBOARD_MAESTRO: DurableObjectNamespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/health") {
|
||||||
|
return Response.json({
|
||||||
|
service: "ligbox-onboard-agents",
|
||||||
|
phases: ["A-vm112", "B-maestro", "C-multi-agent", "D-handoff"],
|
||||||
|
status: "ok",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket upgrade — Fase B
|
||||||
|
if (url.pathname === "/ws/session" && request.headers.get("Upgrade") === "websocket") {
|
||||||
|
const sessionId = url.searchParams.get("session") || crypto.randomUUID();
|
||||||
|
const domain = url.searchParams.get("domain") || "";
|
||||||
|
const id = env.ONBOARD_MAESTRO.idFromName(sessionId);
|
||||||
|
const stub = env.ONBOARD_MAESTRO.get(id);
|
||||||
|
await stub.fetch(new Request("http://do/init", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ sessionId, domain }),
|
||||||
|
}));
|
||||||
|
// Passthrough: client polls REST until full WS agent chat wired
|
||||||
|
return Response.json({
|
||||||
|
ok: true,
|
||||||
|
sessionId,
|
||||||
|
poll: `/api/session/${sessionId}/state`,
|
||||||
|
message: "WebSocket scaffold — use REST /api/session for Fase B",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// REST API
|
||||||
|
const m = url.pathname.match(/^\/api\/session\/([^/]+)(\/.*)?$/);
|
||||||
|
if (!m) {
|
||||||
|
return Response.json({ error: "not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
const sessionId = m[1];
|
||||||
|
const sub = m[2] || "/state";
|
||||||
|
const id = env.ONBOARD_MAESTRO.idFromName(sessionId);
|
||||||
|
const stub = env.ONBOARD_MAESTRO.get(id);
|
||||||
|
return stub.fetch(new Request(`http://do${sub}`, {
|
||||||
|
method: request.method,
|
||||||
|
body: request.method !== "GET" ? await request.text() : undefined,
|
||||||
|
headers: request.headers,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
131
projects/cloudflare-agents/onboard/src/onboard-maestro.ts
Normal file
131
projects/cloudflare-agents/onboard/src/onboard-maestro.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
/**
|
||||||
|
* Spec 037 — OnboardMaestro (Fase B)
|
||||||
|
* Durable Object: orquestra passos; delega agentes especializados (Fase C).
|
||||||
|
*/
|
||||||
|
import { DurableObject } from "cloudflare:workers";
|
||||||
|
import { vm112Get, vm112Post, type Vm112Config } from "./vm112-client";
|
||||||
|
|
||||||
|
export type OnboardState = {
|
||||||
|
sessionId: string;
|
||||||
|
domain: string;
|
||||||
|
step: string;
|
||||||
|
ligboxProjectEmail?: string;
|
||||||
|
projectId?: string;
|
||||||
|
dnsMode?: string;
|
||||||
|
awaitingHuman?: string | null;
|
||||||
|
history: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OnboardMaestro extends DurableObject {
|
||||||
|
private state: OnboardState | null = null;
|
||||||
|
|
||||||
|
private cfg(): Vm112Config {
|
||||||
|
const env = this.env as { VM112_API_BASE: string };
|
||||||
|
if (!this.state?.sessionId) throw new Error("session not initialized");
|
||||||
|
return { baseUrl: env.VM112_API_BASE, sessionId: this.state.sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(msg: string) {
|
||||||
|
if (!this.state) return;
|
||||||
|
this.state.history.push(`${new Date().toISOString()} ${msg}`);
|
||||||
|
if (this.state.history.length > 100) this.state.history.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
if (path === "/init" && request.method === "POST") {
|
||||||
|
const body = (await request.json()) as { sessionId: string; domain: string };
|
||||||
|
this.state = {
|
||||||
|
sessionId: body.sessionId,
|
||||||
|
domain: body.domain.toLowerCase(),
|
||||||
|
step: "init",
|
||||||
|
history: [],
|
||||||
|
awaitingHuman: null,
|
||||||
|
};
|
||||||
|
await this.ctx.storage.put("state", this.state);
|
||||||
|
this.log("initialized");
|
||||||
|
return Response.json({ ok: true, state: this.state });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = (await this.ctx.storage.get<OnboardState>("state")) || this.state;
|
||||||
|
if (!stored) return Response.json({ error: "not initialized" }, { status: 400 });
|
||||||
|
this.state = stored;
|
||||||
|
|
||||||
|
if (path === "/state") {
|
||||||
|
return Response.json({ state: this.state });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/run/proj-mail" && request.method === "POST") {
|
||||||
|
const data = await vm112Post(this.cfg(), "/onboarding/project/provision-email", {
|
||||||
|
domain: this.state.domain,
|
||||||
|
});
|
||||||
|
this.state.ligboxProjectEmail = data.ligbox_project_email as string;
|
||||||
|
this.state.projectId = data.project_id as string;
|
||||||
|
this.state.step = "proj_mail_done";
|
||||||
|
this.state.awaitingHuman = "reveal_password";
|
||||||
|
await this.ctx.storage.put("state", this.state);
|
||||||
|
this.log(`proj-mail ${this.state.ligboxProjectEmail}`);
|
||||||
|
return Response.json({ ok: true, data, state: this.state });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/run/cf-zone" && request.method === "POST") {
|
||||||
|
const prov = await vm112Post(this.cfg(), "/onboarding/dns/cloudflare/provision-zone", {
|
||||||
|
domain: this.state.domain,
|
||||||
|
});
|
||||||
|
const apply = await vm112Post(this.cfg(), "/onboarding/dns/cloudflare/apply", {
|
||||||
|
domain: this.state.domain,
|
||||||
|
zone_id: prov.zone_id,
|
||||||
|
});
|
||||||
|
const verify = await vm112Get(this.cfg(), `/onboarding/dns/verify/${this.state.domain}`);
|
||||||
|
this.state.dnsMode = (apply.dns_mode as string) || "ligbox_cf";
|
||||||
|
this.state.step = "cf_zone_done";
|
||||||
|
await this.ctx.storage.put("state", this.state);
|
||||||
|
this.log("cf-zone apply+verify");
|
||||||
|
return Response.json({ ok: true, prov, apply, verify, state: this.state });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/human/ack" && request.method === "POST") {
|
||||||
|
const body = (await request.json()) as { action: string };
|
||||||
|
if (this.state.awaitingHuman === body.action) {
|
||||||
|
this.state.awaitingHuman = null;
|
||||||
|
await this.ctx.storage.put("state", this.state);
|
||||||
|
}
|
||||||
|
return Response.json({ ok: true, state: this.state });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/run/handoff" && request.method === "POST") {
|
||||||
|
const body = (await request.json()) as { manager_email: string };
|
||||||
|
const data = await vm112Post(this.cfg(), "/onboarding/dns/cloudflare/handoff-manager", {
|
||||||
|
domain: this.state.domain,
|
||||||
|
manager_email: body.manager_email,
|
||||||
|
});
|
||||||
|
this.state.step = "handoff_done";
|
||||||
|
await this.ctx.storage.put("state", this.state);
|
||||||
|
this.log(`handoff ${body.manager_email}`);
|
||||||
|
return Response.json({ ok: true, data, state: this.state });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ error: "not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fase C — agentes especializados (stubs; lógica no Maestro até split) */
|
||||||
|
export class ProjMailAgent extends DurableObject {
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
return Response.json({ agent: "proj-mail", status: "delegate-to-maestro" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CfZoneAgent extends DurableObject {
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
return Response.json({ agent: "cf-zone", status: "delegate-to-maestro" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CfHandoffAgent extends DurableObject {
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
return Response.json({ agent: "cf-handoff", status: "delegate-to-maestro" });
|
||||||
|
}
|
||||||
|
}
|
||||||
37
projects/cloudflare-agents/onboard/src/vm112-client.ts
Normal file
37
projects/cloudflare-agents/onboard/src/vm112-client.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* VM112 onboarding API client — Fase B/C
|
||||||
|
*/
|
||||||
|
export type Vm112Config = { baseUrl: string; sessionId: string };
|
||||||
|
|
||||||
|
export async function vm112Post(
|
||||||
|
cfg: Vm112Config,
|
||||||
|
path: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const res = await fetch(`${cfg.baseUrl}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Onboarding-Session": cfg.sessionId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = (data.detail as string) || (data.message as string) || res.statusText;
|
||||||
|
throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vm112Get(
|
||||||
|
cfg: Vm112Config,
|
||||||
|
path: string,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const res = await fetch(`${cfg.baseUrl}${path}`, {
|
||||||
|
headers: { "X-Onboarding-Session": cfg.sessionId },
|
||||||
|
});
|
||||||
|
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
if (!res.ok) throw new Error(res.statusText);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
13
projects/cloudflare-agents/onboard/tsconfig.json
Normal file
13
projects/cloudflare-agents/onboard/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
30
projects/cloudflare-agents/onboard/wrangler.toml
Normal file
30
projects/cloudflare-agents/onboard/wrangler.toml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
name = "ligbox-onboard-agents"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2025-02-14"
|
||||||
|
account_id = "2d504d7a78dd787696fe1c703fb001c1"
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
VM112_API_BASE = "https://onboard.ligbox.com.br/api"
|
||||||
|
|
||||||
|
# Rota pública (activar após 1º deploy OK)
|
||||||
|
# routes = [{ pattern = "agents.ligbox.com.br/*", zone_name = "ligbox.com.br" }]
|
||||||
|
|
||||||
|
[[durable_objects.bindings]]
|
||||||
|
name = "ONBOARD_MAESTRO"
|
||||||
|
class_name = "OnboardMaestro"
|
||||||
|
|
||||||
|
[[durable_objects.bindings]]
|
||||||
|
name = "PROJ_MAIL_AGENT"
|
||||||
|
class_name = "ProjMailAgent"
|
||||||
|
|
||||||
|
[[durable_objects.bindings]]
|
||||||
|
name = "CF_ZONE_AGENT"
|
||||||
|
class_name = "CfZoneAgent"
|
||||||
|
|
||||||
|
[[durable_objects.bindings]]
|
||||||
|
name = "CF_HANDOFF_AGENT"
|
||||||
|
class_name = "CfHandoffAgent"
|
||||||
|
|
||||||
|
[[migrations]]
|
||||||
|
tag = "v1"
|
||||||
|
new_sqlite_classes = ["OnboardMaestro", "ProjMailAgent", "CfZoneAgent", "CfHandoffAgent"]
|
||||||
|
|
@ -23,6 +23,7 @@ http:
|
||||||
entryPoints: [websecure]
|
entryPoints: [websecure]
|
||||||
tls:
|
tls:
|
||||||
certResolver: letsencrypt
|
certResolver: letsencrypt
|
||||||
|
middlewares: [openpanel-headers]
|
||||||
service: vm123-openpanel
|
service: vm123-openpanel
|
||||||
openpanel-admin:
|
openpanel-admin:
|
||||||
rule: Host(`admin.openpanel.ligbox.com.br`)
|
rule: Host(`admin.openpanel.ligbox.com.br`)
|
||||||
|
|
@ -41,6 +42,11 @@ http:
|
||||||
X-Forwarded-Proto: https
|
X-Forwarded-Proto: https
|
||||||
X-Forwarded-Host: financeiro.ligbox.com.br
|
X-Forwarded-Host: financeiro.ligbox.com.br
|
||||||
X-Script-Name: /odoo
|
X-Script-Name: /odoo
|
||||||
|
openpanel-headers:
|
||||||
|
headers:
|
||||||
|
customRequestHeaders:
|
||||||
|
X-Forwarded-Proto: https
|
||||||
|
X-Forwarded-Host: openpanel.ligbox.com.br
|
||||||
|
|
||||||
services:
|
services:
|
||||||
vm123-foss:
|
vm123-foss:
|
||||||
|
|
@ -54,7 +60,7 @@ http:
|
||||||
vm123-openpanel:
|
vm123-openpanel:
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
servers:
|
servers:
|
||||||
- url: https://10.10.10.123:2083
|
- url: http://10.10.10.123:2083
|
||||||
vm123-openadmin:
|
vm123-openadmin:
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
servers:
|
servers:
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from pydantic import model_validator
|
from pydantic import model_validator
|
||||||
|
|
||||||
from app import auth, backup_codes, mail_notify
|
from app import auth, backup_codes, console_handoff, mail_notify
|
||||||
from app.permissions import ROLES, can_manage_users
|
from app.permissions import ROLES, can_manage_users
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
|
@ -277,3 +277,42 @@ def reset_user_2fa(
|
||||||
email = row["email"] or target
|
email = row["email"] or target
|
||||||
mail_notify.notify_admin_2fa_reset(target, email, user.username)
|
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."}
|
return {"ok": True, "message": f"2FA resetado para {target}. O utilizador pode reconfigurar no login."}
|
||||||
|
|
||||||
|
|
||||||
|
class ConsoleHandoffExchangeRequest(BaseModel):
|
||||||
|
handoff_token: str = Field(min_length=16, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/console-handoff")
|
||||||
|
def create_console_handoff(user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
"""Staff já autenticado no Desk — token único para abrir Console /admin sem re-login."""
|
||||||
|
token, expires_in = console_handoff.create_handoff(user.username, user.role)
|
||||||
|
return {"handoff_token": token, "expires_in": expires_in}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/console-handoff/exchange")
|
||||||
|
def exchange_console_handoff(body: ConsoleHandoffExchangeRequest, request: Request):
|
||||||
|
"""Console SPA troca handoff por JWT Desk (mesmo emissor que login)."""
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
auth.check_login_rate_limit(client_ip)
|
||||||
|
rec = console_handoff.consume_handoff(body.handoff_token)
|
||||||
|
if not rec:
|
||||||
|
raise HTTPException(401, "handoff inválido ou expirado")
|
||||||
|
row = auth._user_row(rec["username"])
|
||||||
|
if not row or not row["active"]:
|
||||||
|
raise HTTPException(401, "utilizador inactivo")
|
||||||
|
user = auth.DeskUser(
|
||||||
|
username=row["username"],
|
||||||
|
role=row["role"],
|
||||||
|
display_name=row["display_name"],
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
access_token, expires_in = auth.create_access_token(user)
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": expires_in,
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
}
|
||||||
|
|
|
||||||
35
projects/ops-desk/api/app/console_handoff.py
Normal file
35
projects/ops-desk/api/app/console_handoff.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Short-lived single-use tokens for Desk → Console SSO (Spec 019/035)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
_HANDOFF: dict[str, dict] = {}
|
||||||
|
_LOCK = Lock()
|
||||||
|
TTL_SEC = 120
|
||||||
|
|
||||||
|
|
||||||
|
def create_handoff(username: str, role: str) -> tuple[str, int]:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
exp = time.time() + TTL_SEC
|
||||||
|
with _LOCK:
|
||||||
|
_purge_expired_locked()
|
||||||
|
_HANDOFF[token] = {"username": username, "role": role, "exp": exp}
|
||||||
|
return token, TTL_SEC
|
||||||
|
|
||||||
|
|
||||||
|
def consume_handoff(token: str) -> dict | None:
|
||||||
|
with _LOCK:
|
||||||
|
rec = _HANDOFF.pop(token.strip(), None)
|
||||||
|
_purge_expired_locked()
|
||||||
|
if not rec or rec["exp"] < time.time():
|
||||||
|
return None
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_expired_locked() -> None:
|
||||||
|
now = time.time()
|
||||||
|
for key in [k for k, v in _HANDOFF.items() if v["exp"] < now]:
|
||||||
|
del _HANDOFF[key]
|
||||||
|
|
@ -13,6 +13,8 @@ from app.collectors.dns import _dig, collect
|
||||||
from app.openpanel_dns import (
|
from app.openpanel_dns import (
|
||||||
fetch_openpanel_bind_records,
|
fetch_openpanel_bind_records,
|
||||||
ns_points_to_openpanel,
|
ns_points_to_openpanel,
|
||||||
|
openpanel_admin_dns_edit_url,
|
||||||
|
openpanel_client_dns_edit_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090")
|
VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090")
|
||||||
|
|
@ -194,16 +196,27 @@ def _edit_links(
|
||||||
role: str,
|
role: str,
|
||||||
authoritative: str = "",
|
authoritative: str = "",
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
from app.permissions import can_open_cloudflare_dns_link
|
from app.permissions import (
|
||||||
|
can_open_cloudflare_dns_link,
|
||||||
|
can_open_openpanel_admin_link,
|
||||||
|
can_open_openpanel_client_link,
|
||||||
|
)
|
||||||
|
|
||||||
links: list[dict[str, Any]] = []
|
links: list[dict[str, Any]] = []
|
||||||
if can_open_cloudflare_dns_link(role) and dns_mode.startswith("ligbox_cf"):
|
if can_open_cloudflare_dns_link(role) and dns_mode.startswith("ligbox_cf"):
|
||||||
zone_name = (zone or {}).get("name") or domain
|
zone_name = (zone or {}).get("name") or domain
|
||||||
|
zone_id = (zone or {}).get("id")
|
||||||
|
cf_url = (
|
||||||
|
f"https://dash.cloudflare.com/?to=/:account/zones/{zone_id}/dns"
|
||||||
|
if zone_id
|
||||||
|
else f"https://dash.cloudflare.com/?to=/:account/{zone_name}/dns"
|
||||||
|
)
|
||||||
links.append(
|
links.append(
|
||||||
{
|
{
|
||||||
"label": "Editar na Cloudflare (Ligbox)",
|
"label": "Editar na Cloudflare (Ligbox staff)",
|
||||||
"provider": "cf_ligbox",
|
"provider": "cf_ligbox",
|
||||||
"url": f"https://dash.cloudflare.com/?to=/:account/{zone_name}/dns",
|
"audience": "staff",
|
||||||
|
"url": cf_url,
|
||||||
"roles": ["super_admin", "ops_lead", "devops", "seo"],
|
"roles": ["super_admin", "ops_lead", "devops", "seo"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -212,22 +225,39 @@ def _edit_links(
|
||||||
{
|
{
|
||||||
"label": "Registro.br / registrador",
|
"label": "Registro.br / registrador",
|
||||||
"provider": "registrar",
|
"provider": "registrar",
|
||||||
|
"audience": "all",
|
||||||
"url": "https://registro.br",
|
"url": "https://registro.br",
|
||||||
"roles": ["super_admin", "ops_lead", "technician", "seo"],
|
"roles": ["super_admin", "ops_lead", "technician", "seo"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if dns_mode == "openpanel_bind" or authoritative == "openpanel_bind":
|
if dns_mode == "openpanel_bind" or authoritative == "openpanel_bind":
|
||||||
from app.permissions import can_open_openpanel_link
|
if can_open_openpanel_admin_link(role):
|
||||||
|
|
||||||
if can_open_openpanel_link(role):
|
|
||||||
from app.openpanel_dns import OPENPANEL_URL
|
|
||||||
|
|
||||||
links.append(
|
links.append(
|
||||||
{
|
{
|
||||||
"label": "Editar no OpenPanel",
|
"label": "Editar DNS (OpenAdmin)",
|
||||||
"provider": "openpanel_bind",
|
"provider": "openpanel_admin",
|
||||||
"url": f"{OPENPANEL_URL}/domains/{domain}/dns",
|
"audience": "staff",
|
||||||
"roles": ["super_admin", "ops_lead", "sales_admin", "seo"],
|
"url": openpanel_admin_dns_edit_url(domain),
|
||||||
|
"roles": [
|
||||||
|
"super_admin",
|
||||||
|
"ops_lead",
|
||||||
|
"sales_admin",
|
||||||
|
"sales_support",
|
||||||
|
"seo",
|
||||||
|
"devops",
|
||||||
|
"technician",
|
||||||
|
"noc",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif can_open_openpanel_client_link(role):
|
||||||
|
links.append(
|
||||||
|
{
|
||||||
|
"label": "Editar DNS (meu painel)",
|
||||||
|
"provider": "openpanel_client",
|
||||||
|
"audience": "client",
|
||||||
|
"url": openpanel_client_dns_edit_url(domain),
|
||||||
|
"roles": ["domain_manager"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return links
|
return links
|
||||||
|
|
@ -309,6 +339,8 @@ async def fetch_dns_viewer(
|
||||||
|
|
||||||
cf_ns_expected = ligbox_ns or ([n for n in public_ns if n.endswith(CF_NS_SUFFIX)] if _ns_match_cloudflare(public_ns) else [])
|
cf_ns_expected = ligbox_ns or ([n for n in public_ns if n.endswith(CF_NS_SUFFIX)] if _ns_match_cloudflare(public_ns) else [])
|
||||||
|
|
||||||
|
from app.permissions import can_open_openpanel_admin_link
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"dns_mode": dns_mode,
|
"dns_mode": dns_mode,
|
||||||
|
|
@ -331,7 +363,11 @@ async def fetch_dns_viewer(
|
||||||
"bind_host": op_bind.get("bind_host"),
|
"bind_host": op_bind.get("bind_host"),
|
||||||
"zone_responds": op_bind.get("zone_responds"),
|
"zone_responds": op_bind.get("zone_responds"),
|
||||||
"ns_on_openpanel": op_bind.get("ns_on_openpanel"),
|
"ns_on_openpanel": op_bind.get("ns_on_openpanel"),
|
||||||
"edit_url": op_bind.get("edit_url"),
|
"edit_url": openpanel_admin_dns_edit_url(domain)
|
||||||
|
if can_open_openpanel_admin_link(role)
|
||||||
|
else op_bind.get("edit_url_client"),
|
||||||
|
"edit_url_admin": openpanel_admin_dns_edit_url(domain),
|
||||||
|
"edit_url_client": op_bind.get("edit_url_client"),
|
||||||
},
|
},
|
||||||
"zone": cf_zone,
|
"zone": cf_zone,
|
||||||
"email_service": cf_payload.get("email_service"),
|
"email_service": cf_payload.get("email_service"),
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,10 @@ async def domain_console_dns_viewer(
|
||||||
)
|
)
|
||||||
if user.role not in STAFF_ROLES:
|
if user.role not in STAFF_ROLES:
|
||||||
payload["edit_links"] = [
|
payload["edit_links"] = [
|
||||||
link for link in payload.get("edit_links", []) if link.get("provider") != "cf_ligbox"
|
link
|
||||||
|
for link in payload.get("edit_links", [])
|
||||||
|
if link.get("provider") not in ("cf_ligbox", "openpanel_admin", "openpanel_bind")
|
||||||
|
and link.get("audience") != "staff"
|
||||||
]
|
]
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,19 @@ OPENPANEL_BIND_HOST = os.getenv("OPENPANEL_BIND_HOST", "10.10.10.123")
|
||||||
OPENPANEL_PUBLIC_IP = os.getenv("OPENPANEL_PUBLIC_DNS_IP", "95.216.14.162")
|
OPENPANEL_PUBLIC_IP = os.getenv("OPENPANEL_PUBLIC_DNS_IP", "95.216.14.162")
|
||||||
OPENPANEL_URL = os.getenv("OPENPANEL_URL", "https://openpanel.ligbox.com.br").rstrip("/")
|
OPENPANEL_URL = os.getenv("OPENPANEL_URL", "https://openpanel.ligbox.com.br").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_openadmin_public_url(url: str) -> str:
|
||||||
|
"""OpenAdmin via Traefik (443) — porta :2087 não é exposta publicamente."""
|
||||||
|
url = (url or "").rstrip("/")
|
||||||
|
if url.endswith(":2087"):
|
||||||
|
return url[:-5]
|
||||||
|
return url or "https://admin.openpanel.ligbox.com.br"
|
||||||
|
|
||||||
|
|
||||||
|
OPENADMIN_URL = _normalize_openadmin_public_url(
|
||||||
|
os.getenv("OPENADMIN_URL", "https://admin.openpanel.ligbox.com.br")
|
||||||
|
)
|
||||||
|
|
||||||
# Ligbox authoritative BIND — common public NS hostnames
|
# Ligbox authoritative BIND — common public NS hostnames
|
||||||
OPENPANEL_NS_HINTS = ("openpanel", "ligbox", "itecnologys")
|
OPENPANEL_NS_HINTS = ("openpanel", "ligbox", "itecnologys")
|
||||||
|
|
||||||
|
|
@ -61,6 +74,18 @@ def ns_points_to_openpanel(domain: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def openpanel_admin_dns_edit_url(domain: str) -> str:
|
||||||
|
"""OpenAdmin DNS Zone Editor — vista staff (Spec 037)."""
|
||||||
|
domain = domain.lower().strip().rstrip(".")
|
||||||
|
return f"{OPENADMIN_URL}/domains/dns?domain={domain}"
|
||||||
|
|
||||||
|
|
||||||
|
def openpanel_client_dns_edit_url(domain: str) -> str:
|
||||||
|
"""Painel cliente OpenPanel — só gerente dono do domínio (nunca staff suporte)."""
|
||||||
|
domain = domain.lower().strip().rstrip(".")
|
||||||
|
return f"{OPENPANEL_URL}/domains/{domain}/dns"
|
||||||
|
|
||||||
|
|
||||||
def bind_zone_responds(domain: str) -> bool:
|
def bind_zone_responds(domain: str) -> bool:
|
||||||
host = OPENPANEL_BIND_HOST
|
host = OPENPANEL_BIND_HOST
|
||||||
if not host:
|
if not host:
|
||||||
|
|
@ -133,6 +158,7 @@ def fetch_openpanel_bind_records(domain: str) -> dict[str, Any]:
|
||||||
"total": len(rows),
|
"total": len(rows),
|
||||||
"email_related": sum(1 for r in rows if r.get("email_related")),
|
"email_related": sum(1 for r in rows if r.get("email_related")),
|
||||||
},
|
},
|
||||||
"edit_url": f"{OPENPANEL_URL}/domains/{domain}/dns",
|
"edit_url": openpanel_admin_dns_edit_url(domain),
|
||||||
|
"edit_url_client": openpanel_client_dns_edit_url(domain),
|
||||||
"error": None if rows or bind_zone_responds(domain) else "Zona não encontrada no BIND OpenPanel",
|
"error": None if rows or bind_zone_responds(domain) else "Zona não encontrada no BIND OpenPanel",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,27 @@ def can_open_cloudflare_dns_link(role: str) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def can_open_openpanel_link(role: str) -> bool:
|
def can_open_openpanel_link(role: str) -> bool:
|
||||||
return role in ("super_admin", "ops_lead", "sales_admin", "sales_support", "seo", "devops")
|
"""Compat — prefer can_open_openpanel_admin_link."""
|
||||||
|
return can_open_openpanel_admin_link(role)
|
||||||
|
|
||||||
|
|
||||||
|
def can_open_openpanel_admin_link(role: str) -> bool:
|
||||||
|
"""Staff suporte/admin — OpenAdmin, nunca painel cliente."""
|
||||||
|
return role in (
|
||||||
|
"super_admin",
|
||||||
|
"ops_lead",
|
||||||
|
"devops",
|
||||||
|
"sales_admin",
|
||||||
|
"sales_support",
|
||||||
|
"seo",
|
||||||
|
"technician",
|
||||||
|
"noc",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_open_openpanel_client_link(role: str) -> bool:
|
||||||
|
"""Gerente dono do domínio — painel cliente (futuro domain-admin JWT)."""
|
||||||
|
return role in ("domain_manager",)
|
||||||
|
|
||||||
|
|
||||||
def can_read_funnel(role: str) -> bool:
|
def can_read_funnel(role: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,23 @@
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function editLinksHtml(links) {
|
function editLinksHtml(links, domain) {
|
||||||
if (!links?.length) return '';
|
const parts = [];
|
||||||
return `<div class="dns-viewer-actions">${links.map((l) => `<a class="btn btn-ghost btn-sm" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.label)} ↗</a>`).join(' ')}</div>`;
|
if (typeof canReadDnsViewer === 'function' && canReadDnsViewer() && domain) {
|
||||||
|
parts.push(
|
||||||
|
`<button type="button" class="btn btn-ghost btn-sm js-console-dns" data-domain="${esc(domain)}">Abrir no Console ↗</button>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (links?.length) {
|
||||||
|
parts.push(
|
||||||
|
...links.map(
|
||||||
|
(l) =>
|
||||||
|
`<a class="btn btn-ghost btn-sm" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.label)} ↗</a>`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!parts.length) return '';
|
||||||
|
return `<div class="dns-viewer-actions">${parts.join(' ')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDnsPanel(data, { compact = false, title = 'DNS do domínio' } = {}) {
|
function renderDnsPanel(data, { compact = false, title = 'DNS do domínio' } = {}) {
|
||||||
|
|
@ -154,7 +168,7 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
${planned}
|
${planned}
|
||||||
${editLinksHtml(data.edit_links)}
|
${editLinksHtml(data.edit_links, data.domain)}
|
||||||
${data.errors?.length ? `<p class="cf-dns-error">${esc(data.errors.join(' · '))}</p>` : ''}`;
|
${data.errors?.length ? `<p class="cf-dns-error">${esc(data.errors.join(' · '))}</p>` : ''}`;
|
||||||
|
|
||||||
if (compact) return inner;
|
if (compact) return inner;
|
||||||
|
|
|
||||||
88
projects/wizard/backend/app/routers/project.py
Normal file
88
projects/wizard/backend/app/routers/project.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""Rotas project identity — Spec 037-C."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.deps import bind_onboarding_session, get_session_from_request
|
||||||
|
from app.services import activity_log, ops_webhook, project_identity
|
||||||
|
from app.services.carbonio import CarbonioError
|
||||||
|
from app.services.domain_format import normalize_domain, validate_primary_domain, invalid_domain_http_detail
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/onboarding/project",
|
||||||
|
tags=["onboarding-project"],
|
||||||
|
dependencies=[Depends(bind_onboarding_session)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionProjectEmailRequest(BaseModel):
|
||||||
|
domain: str = Field(..., min_length=3, max_length=253)
|
||||||
|
display_name: str | None = Field(None, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/provision-email")
|
||||||
|
def provision_project_email(body: ProvisionProjectEmailRequest, request: Request):
|
||||||
|
"""Cria proj_{id}@ligbox.com.br e regista no domain_registry."""
|
||||||
|
get_session_from_request(request)
|
||||||
|
domain = normalize_domain(body.domain)
|
||||||
|
try:
|
||||||
|
validate_primary_domain(domain)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=invalid_domain_http_detail()) from e
|
||||||
|
try:
|
||||||
|
activity_log.info(f"Provision e-mail projeto Ligbox: {domain}", source="project")
|
||||||
|
result = project_identity.provision_project_email(
|
||||||
|
domain,
|
||||||
|
display_name=body.display_name,
|
||||||
|
)
|
||||||
|
if result.get("already_exists"):
|
||||||
|
activity_log.ok(f"E-mail projeto já existe: {result['ligbox_project_email']}", source="project")
|
||||||
|
else:
|
||||||
|
activity_log.ok(f"E-mail projeto criado: {result['ligbox_project_email']}", source="project")
|
||||||
|
ops_webhook.emit_event(
|
||||||
|
"onboard.project.email.created",
|
||||||
|
domain=domain,
|
||||||
|
session_id=get_session_from_request(request),
|
||||||
|
data={
|
||||||
|
"ligbox_project_email": result.get("ligbox_project_email"),
|
||||||
|
"project_id": result.get("project_id"),
|
||||||
|
"already_exists": result.get("already_exists", False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Nunca logar password
|
||||||
|
safe = {k: v for k, v in result.items() if k != "password"}
|
||||||
|
if result.get("password"):
|
||||||
|
safe["password_available"] = True
|
||||||
|
return safe
|
||||||
|
except CarbonioError as e:
|
||||||
|
activity_log.error(f"Carbonio projeto: {e}", source="project")
|
||||||
|
raise HTTPException(status_code=502, detail=str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{domain}")
|
||||||
|
def get_project_identity(domain: str):
|
||||||
|
"""Estado identidade projeto (sem senha)."""
|
||||||
|
domain = normalize_domain(domain)
|
||||||
|
data = project_identity.get_project_identity(domain)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(404, f"Sem identidade projeto para {domain}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{domain}/reveal-password")
|
||||||
|
def reveal_project_password(domain: str, request: Request):
|
||||||
|
"""Uma vez por sessão — devolve senha do vault."""
|
||||||
|
get_session_from_request(request)
|
||||||
|
domain = normalize_domain(domain)
|
||||||
|
vault = project_identity.load_password_vault(domain)
|
||||||
|
if not vault or not vault.get("password"):
|
||||||
|
raise HTTPException(404, "Senha não disponível ou já entregue.")
|
||||||
|
project_identity.mark_password_delivered(domain)
|
||||||
|
return {
|
||||||
|
"domain": domain,
|
||||||
|
"ligbox_project_email": vault.get("ligbox_project_email"),
|
||||||
|
"password": vault["password"],
|
||||||
|
"webmail": f"https://mail.ligbox.com.br/",
|
||||||
|
"message": "Guarde a senha — não será mostrada novamente.",
|
||||||
|
}
|
||||||
102
projects/wizard/backend/app/services/cf_client_accounts.py
Normal file
102
projects/wizard/backend/app/services/cf_client_accounts.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Conta Cloudflare dedicada por cliente — Spec 037 Fase D."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services.cloudflare import CloudflareDNS, CloudflareError
|
||||||
|
from app.services.dns_account_registry import pick_provision_account, provision_ligbox_zone
|
||||||
|
|
||||||
|
_CF_CLIENT_DIR = Path("/var/lib/ligbox-wizard/cf_client_accounts")
|
||||||
|
|
||||||
|
|
||||||
|
def _path(domain: str) -> Path:
|
||||||
|
safe = domain.lower().strip().replace("/", "_")
|
||||||
|
return _CF_CLIENT_DIR / f"{safe}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def save_record(domain: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
domain = domain.lower().strip().rstrip(".")
|
||||||
|
_CF_CLIENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
existing = load_record(domain) or {}
|
||||||
|
merged = {**existing, **data, "domain": domain}
|
||||||
|
merged["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
if not merged.get("created_at"):
|
||||||
|
merged["created_at"] = merged["updated_at"]
|
||||||
|
_path(domain).write_text(json.dumps(merged, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def load_record(domain: str) -> dict[str, Any] | None:
|
||||||
|
p = _path(domain)
|
||||||
|
if not p.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def provision_client_account(
|
||||||
|
domain: str,
|
||||||
|
*,
|
||||||
|
ligbox_project_email: str | None = None,
|
||||||
|
account_name: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Fase D: cria conta CF dedicada (Tenant) ou fallback zona na conta provision.
|
||||||
|
Hoje: fallback = provision_ligbox_zone até Tenant activo.
|
||||||
|
"""
|
||||||
|
domain = domain.lower().strip().rstrip(".")
|
||||||
|
acct = pick_provision_account()
|
||||||
|
if not acct:
|
||||||
|
raise CloudflareError("Nenhuma conta Ligbox configurada.")
|
||||||
|
prov = provision_ligbox_zone(domain, account=acct)
|
||||||
|
zone = prov["zone"]
|
||||||
|
record = save_record(
|
||||||
|
domain,
|
||||||
|
{
|
||||||
|
"ligbox_project_email": ligbox_project_email,
|
||||||
|
"cloudflare_account_id": acct.cloudflare_account_id,
|
||||||
|
"cloudflare_account_name": account_name or f"{domain}",
|
||||||
|
"zone_id": zone.get("id"),
|
||||||
|
"provision_mode": "shared_account_fallback",
|
||||||
|
"handoff_status": "pending",
|
||||||
|
"tenant_dedicated": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"domain": domain,
|
||||||
|
"zone": zone,
|
||||||
|
"created": prov["created"],
|
||||||
|
"account": acct.id,
|
||||||
|
"record": record,
|
||||||
|
"message": (
|
||||||
|
"Zona provisionada (modo fallback conta partilhada). "
|
||||||
|
"Tenant dedicado pendente activação Cloudflare."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def handoff_manager(domain: str, manager_email: str) -> dict[str, Any]:
|
||||||
|
"""Fase D: convite gestor CF — stub até Tenant + members API."""
|
||||||
|
domain = domain.lower().strip().rstrip(".")
|
||||||
|
manager_email = manager_email.lower().strip()
|
||||||
|
rec = load_record(domain) or {}
|
||||||
|
rec = save_record(
|
||||||
|
domain,
|
||||||
|
{
|
||||||
|
**rec,
|
||||||
|
"client_manager_email": manager_email,
|
||||||
|
"handoff_status": "invited_stub",
|
||||||
|
"handoff_note": "Implementar POST /accounts/{id}/members quando Tenant activo.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"domain": domain,
|
||||||
|
"manager_email": manager_email,
|
||||||
|
"handoff_status": rec["handoff_status"],
|
||||||
|
"message": "Handoff registado (stub). Convite CF manual até Tenant API.",
|
||||||
|
}
|
||||||
190
projects/wizard/backend/app/services/dns_account_registry.py
Normal file
190
projects/wizard/backend/app/services/dns_account_registry.py
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
"""
|
||||||
|
Spec 037 — Resolve qual conta Cloudflare Ligbox gere um domínio.
|
||||||
|
|
||||||
|
Deploy: /opt/ligbox-wizard/backend/app/services/dns_account_registry.py
|
||||||
|
Config: /opt/ligbox-wizard/dns-accounts.yaml
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.config import _PROJECT_ROOT, _parse_token_line
|
||||||
|
from app.services.cloudflare import CloudflareDNS, CloudflareError
|
||||||
|
|
||||||
|
_DEFAULT_CONFIG = _PROJECT_ROOT / "dns-accounts.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LigboxCfAccount:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
admin_email: str
|
||||||
|
cloudflare_account_id: str
|
||||||
|
token_file: str
|
||||||
|
|
||||||
|
def load_token(self) -> str:
|
||||||
|
path = _PROJECT_ROOT / self.token_file
|
||||||
|
if not path.is_file():
|
||||||
|
return ""
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
token = _parse_token_line(line)
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def client(self) -> CloudflareDNS:
|
||||||
|
return CloudflareDNS(token=self.load_token())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ZoneMatch:
|
||||||
|
domain: str
|
||||||
|
account: LigboxCfAccount
|
||||||
|
zone: dict[str, Any]
|
||||||
|
zone_id: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dns_mode(self) -> str:
|
||||||
|
return f"ligbox_cf_{self.account.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def load_accounts(config_path: Path | None = None) -> tuple[list[LigboxCfAccount], list[str]]:
|
||||||
|
path = config_path or _DEFAULT_CONFIG
|
||||||
|
if not path.is_file():
|
||||||
|
return [], []
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
order = list(data.get("probe_order") or [])
|
||||||
|
accounts: list[LigboxCfAccount] = []
|
||||||
|
for raw in data.get("accounts") or []:
|
||||||
|
accounts.append(
|
||||||
|
LigboxCfAccount(
|
||||||
|
id=str(raw["id"]),
|
||||||
|
label=str(raw.get("label") or raw["id"]),
|
||||||
|
admin_email=str(raw.get("admin_email") or ""),
|
||||||
|
cloudflare_account_id=str(raw.get("cloudflare_account_id") or ""),
|
||||||
|
token_file=str(raw.get("token_file") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
by_id = {a.id: a for a in accounts}
|
||||||
|
ordered = [by_id[i] for i in order if i in by_id]
|
||||||
|
# append any not listed in probe_order
|
||||||
|
seen = {a.id for a in ordered}
|
||||||
|
for a in accounts:
|
||||||
|
if a.id not in seen:
|
||||||
|
ordered.append(a)
|
||||||
|
return ordered, order
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ligbox_zone(
|
||||||
|
domain: str,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> ZoneMatch | None:
|
||||||
|
"""Procura zona nas contas Ligbox por ordem do catálogo."""
|
||||||
|
domain = domain.lower().strip().rstrip(".")
|
||||||
|
accounts, _ = load_accounts(config_path)
|
||||||
|
errors: list[str] = []
|
||||||
|
for account in accounts:
|
||||||
|
token = account.load_token()
|
||||||
|
if not token:
|
||||||
|
errors.append(f"{account.id}: token ausente")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cf = account.client()
|
||||||
|
zone = cf.get_zone_by_name(domain)
|
||||||
|
if zone and zone.get("id"):
|
||||||
|
return ZoneMatch(
|
||||||
|
domain=domain,
|
||||||
|
account=account,
|
||||||
|
zone=zone,
|
||||||
|
zone_id=str(zone["id"]),
|
||||||
|
)
|
||||||
|
except CloudflareError as e:
|
||||||
|
errors.append(f"{account.id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config_raw(config_path: Path | None = None) -> dict[str, Any]:
|
||||||
|
path = config_path or _DEFAULT_CONFIG
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def pick_provision_account(config_path: Path | None = None) -> LigboxCfAccount | None:
|
||||||
|
"""Conta Ligbox onde o wizard cria zona nova do cliente."""
|
||||||
|
data = _load_config_raw(config_path)
|
||||||
|
default_id = str(data.get("default_provision_account") or "ibytera")
|
||||||
|
accounts, _ = load_accounts(config_path)
|
||||||
|
by_id = {a.id: a for a in accounts}
|
||||||
|
preferred = by_id.get(default_id)
|
||||||
|
if preferred and preferred.load_token():
|
||||||
|
return preferred
|
||||||
|
for account in accounts:
|
||||||
|
if account.load_token():
|
||||||
|
return account
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def provision_ligbox_zone(
|
||||||
|
domain: str,
|
||||||
|
account: LigboxCfAccount | None = None,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Garante zona do cliente na conta CF Ligbox indicada (cria se necessário)."""
|
||||||
|
domain = domain.lower().strip().rstrip(".")
|
||||||
|
account = account or pick_provision_account(config_path)
|
||||||
|
if not account:
|
||||||
|
raise CloudflareError("Nenhuma conta Ligbox com token configurado para provisionar zona.")
|
||||||
|
cf = account.client()
|
||||||
|
existing = cf.get_zone_by_name(domain)
|
||||||
|
if existing:
|
||||||
|
return {"zone": existing, "created": False, "account": account}
|
||||||
|
acc_id = account.cloudflare_account_id or None
|
||||||
|
if acc_id and acc_id.startswith("PREENCHER"):
|
||||||
|
acc_id = None
|
||||||
|
zone = cf.create_zone(domain, account_id=acc_id)
|
||||||
|
return {"zone": zone, "created": True, "account": account}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_payload(domain: str, config_path: Path | None = None) -> dict[str, Any]:
|
||||||
|
match = resolve_ligbox_zone(domain, config_path)
|
||||||
|
provision_acct = pick_provision_account(config_path)
|
||||||
|
base = {
|
||||||
|
"domain": domain.lower().strip().rstrip("."),
|
||||||
|
"paths_available": ["provision_ligbox", "byo", "external"],
|
||||||
|
}
|
||||||
|
if match:
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"matched": True,
|
||||||
|
"account_id": match.account.id,
|
||||||
|
"account_label": f"{match.account.label} ({match.account.admin_email})",
|
||||||
|
"zone_id": match.zone_id,
|
||||||
|
"zone_status": match.zone.get("status"),
|
||||||
|
"dns_mode": match.dns_mode,
|
||||||
|
"can_provision_ligbox": True,
|
||||||
|
"provision_account_id": match.account.id,
|
||||||
|
"paths_available": ["apply_ligbox", "provision_ligbox", "byo", "external"],
|
||||||
|
"message": (
|
||||||
|
f"Domínio encontrado na conta Cloudflare {match.account.label}. "
|
||||||
|
"Pode aplicar apontamentos Ligbox automaticamente."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
prov_id = provision_acct.id if provision_acct else None
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"matched": False,
|
||||||
|
"dns_mode": f"ligbox_cf_{prov_id}" if prov_id else None,
|
||||||
|
"can_provision_ligbox": provision_acct is not None,
|
||||||
|
"provision_account_id": prov_id,
|
||||||
|
"message": (
|
||||||
|
"Domínio novo — o wizard pode criar a zona na Cloudflare gerenciada pela Ligbox "
|
||||||
|
f"({provision_acct.label}) ou usar BYO Cloudflare / registrador."
|
||||||
|
if provision_acct
|
||||||
|
else "Configure tokens Ligbox para provisionar zona, ou use BYO/registrador."
|
||||||
|
),
|
||||||
|
}
|
||||||
102
projects/wizard/backend/app/services/dns_byo_vault.py
Normal file
102
projects/wizard/backend/app/services/dns_byo_vault.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Vault temporário — API Token Cloudflare BYO (Spec 037). Nunca logar o token."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_BYO_DIR = Path("/var/lib/ligbox-wizard/onboarding_dns_byo_tokens")
|
||||||
|
_TTL_HOURS = 24 * 7
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ByoCloudflareSession:
|
||||||
|
domain: str
|
||||||
|
api_token: str
|
||||||
|
zone_id: str
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _vault_path(session_id: str, domain: str) -> Path:
|
||||||
|
key = hashlib.sha256(f"{session_id}:{domain.lower()}".encode()).hexdigest()
|
||||||
|
return _BYO_DIR / f"{key}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_expired() -> None:
|
||||||
|
if not _BYO_DIR.is_dir():
|
||||||
|
return
|
||||||
|
now = _now()
|
||||||
|
for path in _BYO_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
expires = datetime.fromisoformat(data["expires_at"])
|
||||||
|
if expires.tzinfo is None:
|
||||||
|
expires = expires.replace(tzinfo=timezone.utc)
|
||||||
|
if now > expires:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
except (json.JSONDecodeError, KeyError, ValueError):
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def save_byo_token(session_id: str, domain: str, api_token: str, zone_id: str) -> None:
|
||||||
|
session_id = (session_id or "").strip()
|
||||||
|
domain = domain.lower().strip().rstrip(".")
|
||||||
|
if len(session_id) < 8:
|
||||||
|
raise ValueError("Sessão inválida")
|
||||||
|
if not api_token or not zone_id:
|
||||||
|
raise ValueError("Token ou zone_id ausente")
|
||||||
|
_BYO_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_purge_expired()
|
||||||
|
expires = _now() + timedelta(hours=_TTL_HOURS)
|
||||||
|
_vault_path(session_id, domain).write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"domain": domain,
|
||||||
|
"api_token": api_token,
|
||||||
|
"zone_id": zone_id,
|
||||||
|
"expires_at": expires.isoformat(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_byo_token(session_id: str, domain: str) -> ByoCloudflareSession | None:
|
||||||
|
session_id = (session_id or "").strip()
|
||||||
|
domain = domain.lower().strip().rstrip(".")
|
||||||
|
if len(session_id) < 8:
|
||||||
|
return None
|
||||||
|
_purge_expired()
|
||||||
|
path = _vault_path(session_id, domain)
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
expires = datetime.fromisoformat(data["expires_at"])
|
||||||
|
if expires.tzinfo is None:
|
||||||
|
expires = expires.replace(tzinfo=timezone.utc)
|
||||||
|
if _now() > expires:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
return ByoCloudflareSession(
|
||||||
|
domain=data["domain"],
|
||||||
|
api_token=data["api_token"],
|
||||||
|
zone_id=data["zone_id"],
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, KeyError, ValueError):
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def clear_byo_token(session_id: str, domain: str) -> None:
|
||||||
|
session_id = (session_id or "").strip()
|
||||||
|
domain = domain.lower().strip().rstrip(".")
|
||||||
|
if len(session_id) < 8:
|
||||||
|
return
|
||||||
|
_vault_path(session_id, domain).unlink(missing_ok=True)
|
||||||
163
projects/wizard/backend/app/services/project_identity.py
Normal file
163
projects/wizard/backend/app/services/project_identity.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""
|
||||||
|
Spec 037-C — Identidade projeto Ligbox: proj_{id}@ligbox.com.br
|
||||||
|
|
||||||
|
O agente Ligbox cria caixa no Carbonio (ligbox.com.br), entrega senha ao cliente
|
||||||
|
e referencia este e-mail em CF, DNS, registry e webhooks.
|
||||||
|
|
||||||
|
Deploy: /opt/ligbox-wizard/backend/app/services/project_identity.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services import carbonio, domain_registry
|
||||||
|
|
||||||
|
LIGBOX_PROJECT_DOMAIN = "ligbox.com.br"
|
||||||
|
_PROJECT_COUNTER = Path("/var/lib/ligbox-wizard/project_id_counter.txt")
|
||||||
|
_PROJECT_VAULT = Path("/var/lib/ligbox-wizard/project_passwords")
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _project_email(project_id: str) -> str:
|
||||||
|
pid = str(project_id).strip().zfill(3)
|
||||||
|
return f"proj_{pid}@{LIGBOX_PROJECT_DOMAIN}"
|
||||||
|
|
||||||
|
|
||||||
|
def allocate_project_id() -> str:
|
||||||
|
"""ID sequencial (001, 002, …)."""
|
||||||
|
_PROJECT_COUNTER.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
current = 0
|
||||||
|
if _PROJECT_COUNTER.is_file():
|
||||||
|
try:
|
||||||
|
current = int(_PROJECT_COUNTER.read_text(encoding="utf-8").strip() or "0")
|
||||||
|
except ValueError:
|
||||||
|
current = 0
|
||||||
|
nxt = current + 1
|
||||||
|
_PROJECT_COUNTER.write_text(str(nxt), encoding="utf-8")
|
||||||
|
return str(nxt).zfill(3)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_project_password(length: int = 16) -> str:
|
||||||
|
alphabet = string.ascii_letters + string.digits + "!@#$%&*"
|
||||||
|
while True:
|
||||||
|
pwd = "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
if any(c.islower() for c in pwd) and any(c.isupper() for c in pwd) and any(c.isdigit() for c in pwd):
|
||||||
|
return pwd
|
||||||
|
|
||||||
|
|
||||||
|
def _vault_path(domain: str) -> Path:
|
||||||
|
safe = domain.lower().strip().replace("/", "_")
|
||||||
|
return _PROJECT_VAULT / f"{safe}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def save_password_vault(domain: str, project_email: str, password: str) -> None:
|
||||||
|
_PROJECT_VAULT.mkdir(parents=True, exist_ok=True)
|
||||||
|
_vault_path(domain).write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"domain": domain.lower().strip(),
|
||||||
|
"ligbox_project_email": project_email,
|
||||||
|
"password": password,
|
||||||
|
"created_at": _now_iso(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_password_vault(domain: str) -> dict | None:
|
||||||
|
p = _vault_path(domain)
|
||||||
|
if not p.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def provision_project_email(
|
||||||
|
client_domain: str,
|
||||||
|
display_name: str | None = None,
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Cria proj_{id}@ligbox.com.br no Carbonio e regista no domain_registry.
|
||||||
|
Devolve email + password (mostrar uma vez ao cliente).
|
||||||
|
"""
|
||||||
|
client_domain = client_domain.lower().strip().rstrip(".")
|
||||||
|
pid = project_id or allocate_project_id()
|
||||||
|
email = _project_email(pid)
|
||||||
|
label = display_name or f"Projeto {client_domain}"
|
||||||
|
|
||||||
|
existing = domain_registry.get_domain_record(client_domain) or {}
|
||||||
|
if existing.get("ligbox_project_email"):
|
||||||
|
vault = load_password_vault(client_domain)
|
||||||
|
return {
|
||||||
|
"project_id": existing.get("project_id"),
|
||||||
|
"ligbox_project_email": existing["ligbox_project_email"],
|
||||||
|
"already_exists": True,
|
||||||
|
"password": vault.get("password") if vault else None,
|
||||||
|
"webmail": f"https://mail.{LIGBOX_PROJECT_DOMAIN}/",
|
||||||
|
}
|
||||||
|
|
||||||
|
password = generate_project_password()
|
||||||
|
carbonio.create_account_full(
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
display_name=label,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
save_password_vault(client_domain, email, password)
|
||||||
|
|
||||||
|
record = {
|
||||||
|
**existing,
|
||||||
|
"domain": client_domain,
|
||||||
|
"project_id": pid,
|
||||||
|
"ligbox_project_email": email,
|
||||||
|
"ligbox_project_display_name": label,
|
||||||
|
"ligbox_project_created_at": _now_iso(),
|
||||||
|
"ligbox_project_password_delivered": False,
|
||||||
|
}
|
||||||
|
domain_registry.save_domain_record(client_domain, record)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project_id": pid,
|
||||||
|
"ligbox_project_email": email,
|
||||||
|
"password": password,
|
||||||
|
"already_exists": False,
|
||||||
|
"webmail": f"https://mail.{LIGBOX_PROJECT_DOMAIN}/",
|
||||||
|
"message": (
|
||||||
|
f"Caixa {email} criada. Use para Cloudflare e acompanhamento do setup. "
|
||||||
|
"Guarde a senha — não será mostrada novamente."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_identity(client_domain: str) -> dict | None:
|
||||||
|
rec = domain_registry.get_domain_record(client_domain.lower().strip())
|
||||||
|
if not rec or not rec.get("ligbox_project_email"):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"domain": rec["domain"],
|
||||||
|
"project_id": rec.get("project_id"),
|
||||||
|
"ligbox_project_email": rec["ligbox_project_email"],
|
||||||
|
"ligbox_project_display_name": rec.get("ligbox_project_display_name"),
|
||||||
|
"ligbox_project_created_at": rec.get("ligbox_project_created_at"),
|
||||||
|
"ligbox_project_password_delivered": rec.get("ligbox_project_password_delivered", False),
|
||||||
|
"webmail": f"https://mail.{LIGBOX_PROJECT_DOMAIN}/",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def mark_password_delivered(client_domain: str) -> None:
|
||||||
|
rec = domain_registry.get_domain_record(client_domain.lower().strip()) or {}
|
||||||
|
rec["ligbox_project_password_delivered"] = True
|
||||||
|
rec["ligbox_project_password_delivered_at"] = _now_iso()
|
||||||
|
domain_registry.save_domain_record(client_domain.lower().strip(), rec)
|
||||||
308
specs/004-onboard-funnel-events/cloudflare-zone-provision.md
Normal file
308
specs/004-onboard-funnel-events/cloudflare-zone-provision.md
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
# Anexo 004 — Cloudflare: inserir domínio na conta Ligbox (wizard VM112)
|
||||||
|
|
||||||
|
**Parent:** [spec.md](./spec.md) · Spec **004** onboard-funnel-events
|
||||||
|
**Criado:** 2026-06-22 · **Roger**
|
||||||
|
**VM:** **112** (`10.10.10.112`) · API wizard `:8090`
|
||||||
|
**URL pública:** `https://onboard.ligbox.com.br/api/onboarding/`
|
||||||
|
|
||||||
|
Este anexo documenta **onde vive o código**, **funções**, **endpoints**, **fluxo UI** e **specs relacionadas** para criar/inserir um domínio na **conta Cloudflare Ligbox/Ibytera** durante o onboarding (opção «Trazer DNS para o portal»).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumo operacional
|
||||||
|
|
||||||
|
| Acção | Quem | Quando no wizard |
|
||||||
|
|-------|------|------------------|
|
||||||
|
| Validar domínio (formato + Carbonio) | `POST /validate-domain` | Passo 0 — antes do CF |
|
||||||
|
| **Criar zona na conta CF** | `POST /dns/cloudflare/provision-zone` | Passo DNS — «Trazer para o portal» |
|
||||||
|
| Aplicar MX/SPF/DKIM/… | `POST /dns/cloudflare/apply` | Após NS no registrador (ou auto sandbox) |
|
||||||
|
| Verificar propagação | `GET /dns/verify/{domain}` | Antes de criar conta |
|
||||||
|
| Emitir `dns.applied` (funil Desk) | `apply_cloudflare_dns` router | Após apply OK — ver [research.md](./research.md) |
|
||||||
|
|
||||||
|
**Nota:** `provision-zone` **cria a zona**; `apply` **escreve registos DNS** dentro da zona. São passos distintos.
|
||||||
|
|
||||||
|
**Spec 037 (2026-06-22):** três contas Ligbox (`ligit`, `itecnologys`, `ibytera`). `GET /dns/resolve/{domain}` descobre a conta; domínio fora das três → BYO (`connect-custom`) ou registrador. Ver [037-dns-multi-cloudflare-orchestration](../037-dns-multi-cloudflare-orchestration/spec.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Onde vive o código (VM112)
|
||||||
|
|
||||||
|
| Caminho | Papel |
|
||||||
|
|---------|--------|
|
||||||
|
| `/opt/ligbox-wizard/backend/app/services/cloudflare.py` | Cliente API Cloudflare (`CloudflareDNS`) |
|
||||||
|
| `/opt/ligbox-wizard/backend/app/routers/onboarding.py` | Rotas HTTP onboarding + hooks webhook funil |
|
||||||
|
| `/opt/ligbox-wizard/backend/app/services/dns_verify.py` | Verificação MX/A após apply |
|
||||||
|
| `/opt/ligbox-wizard/backend/app/config.py` | `cloudflare_account_id`, `load_cloudflare_token_from_secrets()` |
|
||||||
|
| `/opt/ligbox-wizard/secrets/cloudflare.token` | Token API (não commitar) |
|
||||||
|
| `/opt/ligbox-wizard/secrets/README.txt` | Instruções token |
|
||||||
|
| Frontend: `frontend/src/App.jsx` (ou deploy equivalente) | `choosePortalDns()`, `applyPortalDnsRecords()` |
|
||||||
|
| Serviço: `systemctl status ligbox-wizard` | API `:8090` |
|
||||||
|
|
||||||
|
**Repo espelho (dev):** `ibytera-mail-portal` / `ligbox-wizard` no monorepo ou LAPTOP — deploy para VM112 via `scp` + restart.
|
||||||
|
|
||||||
|
**Desk VM122 (só leitura):** `projects/ops-desk/api/app/cloudflare_dns.py` — `GET /api/v1/dns/cloudflare/records` — **não cria zona**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuração e credenciais
|
||||||
|
|
||||||
|
| Variável / ficheiro | Descrição |
|
||||||
|
|---------------------|-----------|
|
||||||
|
| `secrets/cloudflare.token` | API Token Cloudflare (conta Ligbox/Ibytera) |
|
||||||
|
| `settings.cloudflare_account_id` | Account ID onde zonas são criadas (`POST /zones` body `account.id`) |
|
||||||
|
| `settings.mail_public_ip` | IP para registos A/MX em `mail_dns_records()` |
|
||||||
|
| `settings.onboard_sandbox` | Se true → modo sandbox (sem CF real) |
|
||||||
|
| Token prefix `sandbox_` | Também activa `is_cloudflare_sandbox()` |
|
||||||
|
|
||||||
|
### Constitution — segurança token
|
||||||
|
|
||||||
|
Fonte: [`.specify/memory/constitution.md`](../../.specify/memory/constitution.md)
|
||||||
|
|
||||||
|
> **Cloudflare API:** Tokens com scope mínimo (zone DNS, **não** account-wide).
|
||||||
|
|
||||||
|
**Scopes mínimos para provision-zone (produção):**
|
||||||
|
|
||||||
|
| Scope | Motivo |
|
||||||
|
|-------|--------|
|
||||||
|
| `Account - Zone - Edit` | Criar zona na conta |
|
||||||
|
| `Zone - Create` | `POST /zones` |
|
||||||
|
| `Zone - DNS - Edit` | `upsert_record` (MX, SPF, TXT, A) |
|
||||||
|
| `Zone - Read` | `get_zone_by_name`, list records |
|
||||||
|
|
||||||
|
Mensagem de erro no código se faltar permissão:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Token sem permissão para criar zonas.
|
||||||
|
Adicione «Account - Zone - Edit» e «Zone - Create» ao API token Ibytera.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Funções que criam/inserem a zona (conta CF)
|
||||||
|
|
||||||
|
Ficheiro: `backend/app/services/cloudflare.py`
|
||||||
|
|
||||||
|
### Helpers
|
||||||
|
|
||||||
|
| Função | Descrição |
|
||||||
|
|--------|-----------|
|
||||||
|
| `is_cloudflare_sandbox(token?)` | `True` se `onboard_sandbox` ou token `sandbox_*` |
|
||||||
|
| `sandbox_zone(domain)` | Zona fictícia para testes (sem API CF) |
|
||||||
|
| `wizard_nameservers(zone)` | Extrai NS da zona para UI |
|
||||||
|
| `mail_dns_records(domain, mail_ip, aliases?)` | Lista dicts MX/SPF/DKIM/A para apply |
|
||||||
|
|
||||||
|
### Classe `CloudflareDNS`
|
||||||
|
|
||||||
|
| Método | API CF | Descrição |
|
||||||
|
|--------|--------|-----------|
|
||||||
|
| `verify_token()` | `GET /user/tokens/verify` ou equivalente | Valida token antes de operações |
|
||||||
|
| `get_zone_by_name(domain)` | `GET /zones?name=` | Procura zona na conta |
|
||||||
|
| **`create_zone(domain, account_id?)`** | **`POST /zones`** | **Cria zona `type: full` na conta Ibytera**; se já existe (1061/1093) devolve existente |
|
||||||
|
| **`ensure_zone(domain)`** | composto | **`get_zone_by_name` → se null → `create_zone`** — **função principal de inserção** |
|
||||||
|
| `list_zone_names()` | `GET /zones` paginado | Lista domínios na conta |
|
||||||
|
| `list_records(zone_id)` | `GET /zones/{id}/dns_records` | Registos actuais |
|
||||||
|
| `upsert_record(zone_id, type, name, content, …)` | POST/PUT dns_records | Idempotente (81058 = já existe) |
|
||||||
|
| `fqdn(name, zone_name)` | — | Normaliza `@` e subdomínios |
|
||||||
|
|
||||||
|
### Corpo `create_zone` (produção)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "exemplo.com.br",
|
||||||
|
"account": { "id": "<cloudflare_account_id>" },
|
||||||
|
"type": "full",
|
||||||
|
"jump_start": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints API (wizard — passo DNS onboard)
|
||||||
|
|
||||||
|
Base: `/api/onboarding` · Header obrigatório: `X-Onboarding-Session: <session_id>`
|
||||||
|
|
||||||
|
| Método | Path | Handler | Função |
|
||||||
|
|--------|------|---------|--------|
|
||||||
|
| POST | `/validate-domain` | `validate_domain` | Valida domínio; emite `onboarding.started` + `domain.validated` |
|
||||||
|
| GET | `/dns/cloudflare/status/{domain}` | `cloudflare_zone_status` | Estado zona (configurado?, NS, pending) |
|
||||||
|
| **POST** | **`/dns/cloudflare/provision-zone`** | **`provision_cloudflare_zone`** | **`ensure_zone(domain)` — insere na conta CF** |
|
||||||
|
| **POST** | **`/dns/cloudflare/apply`** | **`apply_cloudflare_dns`** | Aplica registos mail; emite **`dns.applied`** |
|
||||||
|
| GET | `/dns/verify/{domain}` | `verify_dns` | Verifica propagação DNS pública |
|
||||||
|
| GET | `/dns/portal-onboarding/{domain}` | `portal_dns_onboarding` | Payload UI passos NS |
|
||||||
|
| GET | `/dns/instructions/{domain}` | `dns_instructions` | Instruções alternativas DNS |
|
||||||
|
| POST | `/account/create` | `create_account` | Conta Carbonio + infra (após DNS OK) |
|
||||||
|
|
||||||
|
### `provision-zone` — request/response
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "domain": "exemplo.com.br" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (campos principais):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"domain": "exemplo.com.br",
|
||||||
|
"zone_id": "…",
|
||||||
|
"nameservers": ["…ns.cloudflare.com", "…"],
|
||||||
|
"message": "Domínio … criado na Cloudflare Ligbox",
|
||||||
|
"sandbox": false,
|
||||||
|
"status": { "nameservers": ["…"] },
|
||||||
|
"steps": [ … ],
|
||||||
|
"verification": { … }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Em **sandbox**, `provision-zone` pode também aplicar registos simulados e preencher `verification`.
|
||||||
|
|
||||||
|
### `apply` — request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"domain": "exemplo.com.br",
|
||||||
|
"zone_id": "opcional-se-ja-conhecido",
|
||||||
|
"mail_aliases": ["suporte", "vendas"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Se zona não existir na conta → HTTP **422** `zone_not_in_account` + `use_portal_onboarding: true`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxo no wizard (UI)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as Cliente
|
||||||
|
participant UI as Wizard App.jsx
|
||||||
|
participant API as VM112 :8090
|
||||||
|
participant CF as Cloudflare API
|
||||||
|
participant OPS as Desk VM122
|
||||||
|
|
||||||
|
U->>UI: Passo 0 — domínio
|
||||||
|
UI->>API: POST /validate-domain
|
||||||
|
API-->>OPS: webhook domain.validated (+ started 1x)
|
||||||
|
|
||||||
|
U->>UI: Escolhe «DNS no portal Ligbox»
|
||||||
|
UI->>API: POST /dns/cloudflare/provision-zone
|
||||||
|
API->>CF: ensure_zone (create se necessário)
|
||||||
|
CF-->>API: zone_id + nameservers
|
||||||
|
API-->>UI: nameservers + passos
|
||||||
|
|
||||||
|
U->>U: Altera NS no registrador
|
||||||
|
UI->>API: POST /dns/cloudflare/apply
|
||||||
|
API->>CF: upsert_record (MX, SPF, …)
|
||||||
|
API-->>OPS: webhook dns.applied
|
||||||
|
UI->>API: GET /dns/verify/{domain}
|
||||||
|
|
||||||
|
U->>UI: Passo conta admin
|
||||||
|
UI->>API: POST /account/create
|
||||||
|
```
|
||||||
|
|
||||||
|
### Funções frontend (referência)
|
||||||
|
|
||||||
|
| Função JS | Endpoint |
|
||||||
|
|-----------|----------|
|
||||||
|
| `choosePortalDns()` | `POST /onboarding/dns/cloudflare/provision-zone` |
|
||||||
|
| `applyPortalDnsRecords()` / `applyPortalDns()` | `POST /onboarding/dns/cloudflare/apply` |
|
||||||
|
| `refreshCfStatus()` | `GET /onboarding/dns/cloudflare/status/{domain}` |
|
||||||
|
|
||||||
|
Ficheiro típico: `frontend/src/App.jsx` (passo DNS do onboard).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integração funil Desk (Spec 004)
|
||||||
|
|
||||||
|
| Marco wizard | Evento webhook | Hook em `onboarding.py` |
|
||||||
|
|--------------|----------------|------------------------|
|
||||||
|
| Primeiro validate-domain OK | `onboarding.started` | `validate_domain` |
|
||||||
|
| validate-domain OK | `domain.validated` | `validate_domain` |
|
||||||
|
| **apply CF OK** | **`dns.applied`** | **`apply_cloudflare_dns`** (~L592) |
|
||||||
|
| Infra pós-conta | `infra.synced` | `create_account` |
|
||||||
|
| Fim OK | `onboarding.completed` | `create_account` |
|
||||||
|
| Erro crítico | `onboarding.failed` | `create_account` except |
|
||||||
|
|
||||||
|
Fase funil: `domain_validated` → **`dns_applied`** → `account_created` → …
|
||||||
|
|
||||||
|
Ver [research.md](./research.md) tabela hook points.
|
||||||
|
|
||||||
|
**Gap conhecido:** `provision-zone` **não** emite evento próprio — só cria zona. O funil avança para `dns_applied` no **apply**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Specs e documentos relacionados
|
||||||
|
|
||||||
|
| # | Documento | Relação com Cloudflare onboard |
|
||||||
|
|---|-----------|-------------------------------|
|
||||||
|
| **004** | [spec.md](./spec.md) | Funil + `dns.applied` |
|
||||||
|
| **004** | [research.md](./research.md) | Hook `apply_cloudflare_dns` |
|
||||||
|
| **004** | [contracts/webhook-funnel-events.md](./contracts/webhook-funnel-events.md) | Payload eventos funil |
|
||||||
|
| **001** | [specs/001-webhook-vm112-integration/spec.md](../001-webhook-vm112-integration/spec.md) | Webhook base + `domain.validated` |
|
||||||
|
| **012** | [specs/012-abandoned-onboarding-lead/spec.md](../012-abandoned-onboarding-lead/spec.md) | `onboarding.started` em `account/create` (não validate) |
|
||||||
|
| **016** | [specs/016-onboard-self-service-prefill/spec.md](../016-onboard-self-service-prefill/spec.md) | Pré-preenche domínio passo 0 |
|
||||||
|
| **025** | [specs/025-wizard-onboarding-continuity/spec.md](../025-wizard-onboarding-continuity/spec.md) | Fluxo domínio/DNS idempotente |
|
||||||
|
| **017** | [specs/017-vm112-domain-orchestration/spec.md](../017-vm112-domain-orchestration/spec.md) | **Purge** — apaga zona CF (passo 5) |
|
||||||
|
| **010** | [specs/010-desk-assist-takeover/spec.md](../010-desk-assist-takeover/spec.md) | `dns.revalidate` / assist passo DNS |
|
||||||
|
| **010** | [specs/010-admin-domain-validation/spec.md](../010-admin-domain-validation/spec.md) | Checks infra (não cria zona) |
|
||||||
|
| **019** | [specs/019-email-migration-vm122-execution/spec.md](../019-email-migration-vm122-execution/spec.md) | MX CF bloqueado até gate DNS |
|
||||||
|
| **035** | [specs/035-ligbox-mail-bundles-foss-openpanel/spec.md](../035-ligbox-mail-bundles-foss-openpanel/spec.md) | Wizard: MX, SPF, DMARC records |
|
||||||
|
| **027** | [specs/027-desk-rbac-function-matrix/spec.md](../027-desk-rbac-function-matrix/spec.md) | RBAC `cloudflare_dns.read` Desk |
|
||||||
|
| — | [constitution.md](../../.specify/memory/constitution.md) | Token CF scope mínimo |
|
||||||
|
| — | `chat-bruto/CHAT_BRUTO_WIZARD_VM112_20260619.txt` | E2E `provision-zone`, sandbox, fixes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testes rápidos (curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SESSION="test-$(date +%s)"
|
||||||
|
BASE="https://onboard.ligbox.com.br/api/onboarding"
|
||||||
|
HDR=(-H "X-Onboarding-Session: $SESSION" -H "Content-Type: application/json")
|
||||||
|
|
||||||
|
# 1. Validar domínio
|
||||||
|
curl -s -X POST "$BASE/validate-domain" "${HDR[@]}" \
|
||||||
|
-d '{"domain":"exemplo.com.br"}' | jq .
|
||||||
|
|
||||||
|
# 2. Estado CF
|
||||||
|
curl -s "$BASE/dns/cloudflare/status/exemplo.com.br" "${HDR[@]}" | jq .
|
||||||
|
|
||||||
|
# 3. Inserir zona na conta Ligbox
|
||||||
|
curl -s -X POST "$BASE/dns/cloudflare/provision-zone" "${HDR[@]}" \
|
||||||
|
-d '{"domain":"exemplo.com.br"}' | jq .
|
||||||
|
|
||||||
|
# 4. Aplicar registos mail (após NS)
|
||||||
|
curl -s -X POST "$BASE/dns/cloudflare/apply" "${HDR[@]}" \
|
||||||
|
-d '{"domain":"exemplo.com.br"}' | jq .
|
||||||
|
|
||||||
|
# 5. Verificar DNS
|
||||||
|
curl -s "$BASE/dns/verify/exemplo.com.br" "${HDR[@]}" | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
LAN directo: substituir host por `http://10.10.10.112:8090/api/onboarding`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erros comuns
|
||||||
|
|
||||||
|
| Sintoma | Causa provável | Acção |
|
||||||
|
|---------|----------------|-------|
|
||||||
|
| `Token Cloudflare não configurado` | Falta `secrets/cloudflare.token` | Criar token + restart wizard |
|
||||||
|
| `Token sem permissão para criar zonas` | Scopes insuficientes | Account Zone Edit + Zone Create |
|
||||||
|
| `zone_not_in_account` no apply | `provision-zone` não foi corrido | Correr provision primeiro |
|
||||||
|
| Zona noutra conta CF | Domínio já em CF cliente | Opção «manter no provedor» ou transfer |
|
||||||
|
| Sandbox sem CF real | Token `sandbox_` ou `onboard_sandbox` | Esperado em dev |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manutenção deste anexo
|
||||||
|
|
||||||
|
Actualizar quando:
|
||||||
|
|
||||||
|
- Alterar `cloudflare.py` ou rotas em `onboarding.py`
|
||||||
|
- Novos eventos funil ligados a CF
|
||||||
|
- Mudança de conta Cloudflare ou scopes token
|
||||||
|
- Refactor frontend passo DNS
|
||||||
|
|
||||||
|
Após editar: `refresh-spec-driver.sh` no CT130 (regra `spec-driver-sync`).
|
||||||
|
|
@ -10,7 +10,15 @@
|
||||||
|
|
||||||
**Depends on**: `001-webhook-vm112-integration` (MVP `account.created` entregue)
|
**Depends on**: `001-webhook-vm112-integration` (MVP `account.created` entregue)
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
**Anexos técnicos:**
|
||||||
|
|
||||||
|
| Documento | Conteúdo |
|
||||||
|
|-----------|----------|
|
||||||
|
| [cloudflare-zone-provision.md](./cloudflare-zone-provision.md) | Inserir domínio na conta Cloudflare Ligbox — código VM112, funções, endpoints, fluxo UI |
|
||||||
|
| [research.md](./research.md) | Hook points `onboarding.py` |
|
||||||
|
| [contracts/webhook-funnel-events.md](./contracts/webhook-funnel-events.md) | Contrato eventos funil |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### User Story 1 — Visibilidade do funil activo (Priority: P1)
|
### User Story 1 — Visibilidade do funil activo (Priority: P1)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
## Phase B — Portal VM112
|
## Phase B — Portal VM112
|
||||||
|
|
||||||
- [ ] T006 `validate-domain`: emit `onboarding.started` (1x) + `domain.validated`
|
- [ ] T006 `validate-domain`: emit `onboarding.started` (1x) + `domain.validated`
|
||||||
- [ ] T007 `apply_cloudflare_dns`: emit `dns.applied`
|
- [ ] T007 `apply_cloudflare_dns`: emit `dns.applied` — ver [cloudflare-zone-provision.md](./cloudflare-zone-provision.md)
|
||||||
- [ ] T008 `create_account`: emit `infra.synced` + `onboarding.completed`
|
- [ ] T008 `create_account`: emit `infra.synced` + `onboarding.completed`
|
||||||
- [ ] T009 `create_account` except: emit `onboarding.failed`
|
- [ ] T009 `create_account` except: emit `onboarding.failed`
|
||||||
- [ ] T010 Sync código para `/opt/ibytera-mail-portal/` VM112 + restart
|
- [ ] T010 Sync código para `/opt/ibytera-mail-portal/` VM112 + restart
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { loginDesk } from '../lib/auth'
|
||||||
|
|
||||||
|
export default function AdminLoginGate({ onSuccess }) {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await loginDesk(username, password)
|
||||||
|
onSuccess?.()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Login falhou')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card login-card" style={{ maxWidth: 420 }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Área Admin</h2>
|
||||||
|
<p className="dns-viewer-meta">
|
||||||
|
Se já entrou no <strong>Desk</strong> neste browser, actualize esta página (F5) — a sessão
|
||||||
|
partilha automaticamente entre <code>desk</code> e <code>console</code>.
|
||||||
|
Alternativa: no painel DNS do setup, <strong>Abrir no Console ↗</strong>.
|
||||||
|
Só precisa de password aqui se abriu o Console directamente, sem Desk.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<label className="form-label">
|
||||||
|
Utilizador
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-label">
|
||||||
|
Senha
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-input"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error ? <p className="error-text">{error}</p> : null}
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Entrando…' : 'Entrar'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,57 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
import { clearSession, getUser } from '../lib/auth'
|
import AdminLoginGate from '../components/AdminLoginGate'
|
||||||
|
import {
|
||||||
|
bootstrapAuthFromUrl,
|
||||||
|
clearSession,
|
||||||
|
getUser,
|
||||||
|
isLoggedIn,
|
||||||
|
} from '../lib/auth'
|
||||||
|
|
||||||
const adminNav = [
|
const adminNav = [
|
||||||
{ to: '/admin', label: 'Início', end: true },
|
{ to: '/admin', label: 'Início', end: true },
|
||||||
{ to: '/admin/dominio', label: 'Domínio & DNS' },
|
{ to: '/admin/dominio', label: 'Domínio & DNS' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const STAFF_ROLES = new Set([
|
||||||
|
'super_admin',
|
||||||
|
'ops_lead',
|
||||||
|
'technician',
|
||||||
|
'noc',
|
||||||
|
'seo',
|
||||||
|
'devops',
|
||||||
|
'developer',
|
||||||
|
'sales_admin',
|
||||||
|
'sales_support',
|
||||||
|
])
|
||||||
|
|
||||||
export default function AdminShell() {
|
export default function AdminShell() {
|
||||||
|
const [authReady, setAuthReady] = useState(false)
|
||||||
|
const [authed, setAuthed] = useState(isLoggedIn())
|
||||||
const user = getUser()
|
const user = getUser()
|
||||||
|
const subtitle = user && STAFF_ROLES.has(user.role) ? 'Staff · suporte Ligbox' : 'Gerente de domínio'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
bootstrapAuthFromUrl().then((ok) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAuthed(ok)
|
||||||
|
setAuthReady(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleLoginSuccess() {
|
||||||
|
setAuthed(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
clearSession()
|
||||||
|
setAuthed(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
|
|
@ -16,7 +60,7 @@ export default function AdminShell() {
|
||||||
<div className="sidebar-logo" aria-hidden="true">LB</div>
|
<div className="sidebar-logo" aria-hidden="true">LB</div>
|
||||||
<div>
|
<div>
|
||||||
<h1>Ligbox Console</h1>
|
<h1>Ligbox Console</h1>
|
||||||
<p className="sidebar-sub">Gerente de domínio</p>
|
<p className="sidebar-sub">{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="nav-section-label">Administração</p>
|
<p className="nav-section-label">Administração</p>
|
||||||
|
|
@ -36,17 +80,31 @@ export default function AdminShell() {
|
||||||
<nav>
|
<nav>
|
||||||
<NavLink to="/" className="nav-link">← Console Ops</NavLink>
|
<NavLink to="/" className="nav-link">← Console Ops</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
{user ? (
|
{authed && user ? (
|
||||||
<p className="status-pill" style={{ marginTop: '1.5rem' }}>
|
<p className="status-pill" style={{ marginTop: '1.5rem' }}>
|
||||||
{user.display_name || user.username}
|
{user.display_name || user.username}
|
||||||
|
{user.role ? ` · ${user.role}` : ''}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<button type="button" className="btn btn-ghost btn-sm" style={{ marginTop: '0.5rem' }} onClick={clearSession}>
|
{authed ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
style={{ marginTop: '0.5rem' }}
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
Sair
|
Sair
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
<main className="main">
|
<main className="main">
|
||||||
|
{!authReady ? (
|
||||||
|
<p className="loading">A validar sessão…</p>
|
||||||
|
) : !authed ? (
|
||||||
|
<AdminLoginGate onSuccess={handleLoginSuccess} />
|
||||||
|
) : (
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,24 @@
|
||||||
const API_BASE = (import.meta.env.VITE_API_URL || 'https://api.ops.ligbox.com.br').replace(/\/$/, '')
|
const API_BASE = (import.meta.env.VITE_API_URL || 'https://api.ops.ligbox.com.br').replace(/\/$/, '')
|
||||||
|
|
||||||
|
function wrapNetworkError(err) {
|
||||||
|
const msg = String(err?.message || err || '')
|
||||||
|
if (msg === 'Failed to fetch' || err?.name === 'TypeError') {
|
||||||
|
return new Error(
|
||||||
|
'Ligação à API falhou (rede/CORS). Faça Ctrl+Shift+R e confirme que está em console.ligbox.com.br.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return err instanceof Error ? err : new Error(msg || 'Erro de rede')
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiGet(path, { token } = {}) {
|
export async function apiGet(path, { token } = {}) {
|
||||||
const headers = { Accept: 'application/json' }
|
const headers = { Accept: 'application/json' }
|
||||||
if (token) headers.Authorization = `Bearer ${token}`
|
if (token) headers.Authorization = `Bearer ${token}`
|
||||||
const res = await fetch(`${API_BASE}${path}`, { headers, credentials: 'include' })
|
let res
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_BASE}${path}`, { headers })
|
||||||
|
} catch (e) {
|
||||||
|
throw wrapNetworkError(e)
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = new Error(`HTTP ${res.status}`)
|
const err = new Error(`HTTP ${res.status}`)
|
||||||
err.status = res.status
|
err.status = res.status
|
||||||
|
|
@ -15,12 +30,16 @@ export async function apiGet(path, { token } = {}) {
|
||||||
export async function apiPost(path, body, { token } = {}) {
|
export async function apiPost(path, body, { token } = {}) {
|
||||||
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' }
|
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' }
|
||||||
if (token) headers.Authorization = `Bearer ${token}`
|
if (token) headers.Authorization = `Bearer ${token}`
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
let res
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_BASE}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
} catch (e) {
|
||||||
|
throw wrapNetworkError(e)
|
||||||
|
}
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = new Error(data.detail || data.message || `HTTP ${res.status}`)
|
const err = new Error(data.detail || data.message || `HTTP ${res.status}`)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,22 @@
|
||||||
const TOKEN_KEY = 'ligbox_console_token'
|
const TOKEN_KEY = 'ligbox_console_token'
|
||||||
const USER_KEY = 'ligbox_console_user'
|
const USER_KEY = 'ligbox_console_user'
|
||||||
|
const SSO_COOKIE = 'ligbox_sso_token'
|
||||||
|
|
||||||
|
function readSsoCookieToken() {
|
||||||
|
const re = new RegExp(`(?:^|; )${SSO_COOKIE.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}=([^;]*)`)
|
||||||
|
const m = document.cookie.match(re)
|
||||||
|
return m ? decodeURIComponent(m[1]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const DNS_VIEWER_ROLES = new Set([
|
||||||
|
'super_admin',
|
||||||
|
'ops_lead',
|
||||||
|
'technician',
|
||||||
|
'noc',
|
||||||
|
'seo',
|
||||||
|
'devops',
|
||||||
|
'developer',
|
||||||
|
])
|
||||||
|
|
||||||
export function getToken() {
|
export function getToken() {
|
||||||
return localStorage.getItem(TOKEN_KEY)
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
|
|
@ -27,12 +44,92 @@ export function isLoggedIn() {
|
||||||
return Boolean(getToken())
|
return Boolean(getToken())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canReadDnsViewer(role) {
|
||||||
|
return DNS_VIEWER_ROLES.has(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
function userFromLoginResponse(data) {
|
||||||
|
return (
|
||||||
|
data.user || {
|
||||||
|
username: data.username,
|
||||||
|
role: data.role,
|
||||||
|
display_name: data.display_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshSession() {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return false
|
||||||
|
const { apiGet } = await import('./api')
|
||||||
|
const me = await apiGet('/api/v1/auth/me', { token })
|
||||||
|
setSession(token, {
|
||||||
|
username: me.username,
|
||||||
|
role: me.role,
|
||||||
|
display_name: me.display_name,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeDeskHandoff(handoffToken) {
|
||||||
|
const { apiPost } = await import('./api')
|
||||||
|
const data = await apiPost('/api/v1/auth/console-handoff/exchange', {
|
||||||
|
handoff_token: handoffToken,
|
||||||
|
})
|
||||||
|
setSession(data.access_token, userFromLoginResponse(data))
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrapFromSsoCookie() {
|
||||||
|
const cookieToken = readSsoCookieToken()
|
||||||
|
if (!cookieToken) return false
|
||||||
|
setSession(cookieToken, getUser() || {})
|
||||||
|
try {
|
||||||
|
await refreshSession()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
clearSession()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handoff URL, cookie Desk (.ligbox.com.br) ou JWT Console — uma vez no shell /admin. */
|
||||||
|
export async function bootstrapAuthFromUrl() {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const handoff = params.get('desk_handoff')
|
||||||
|
if (handoff) {
|
||||||
|
try {
|
||||||
|
await exchangeDeskHandoff(handoff)
|
||||||
|
params.delete('desk_handoff')
|
||||||
|
const qs = params.toString()
|
||||||
|
const path = `${window.location.pathname}${qs ? `?${qs}` : ''}`
|
||||||
|
window.history.replaceState({}, '', path)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('desk handoff failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
const fromCookie = await bootstrapFromSsoCookie()
|
||||||
|
if (fromCookie) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await refreshSession()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
clearSession()
|
||||||
|
const fromCookie = await bootstrapFromSsoCookie()
|
||||||
|
return fromCookie
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function loginDesk(username, password) {
|
export async function loginDesk(username, password) {
|
||||||
const { apiPost } = await import('./api')
|
const { apiPost } = await import('./api')
|
||||||
const data = await apiPost('/api/v1/auth/login', { username, password })
|
const data = await apiPost('/api/v1/auth/login', { username, password })
|
||||||
if (data.mfa_required) {
|
if (data.mfa_required) {
|
||||||
throw new Error('2FA necessário — use o Desk para login completo')
|
throw new Error('2FA necessário — entre pelo Desk e use «Abrir no Console» no DNS')
|
||||||
}
|
}
|
||||||
setSession(data.access_token, data.user)
|
setSession(data.access_token, userFromLoginResponse(data))
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import DnsViewerPanel from '../../components/DnsViewerPanel'
|
import DnsViewerPanel from '../../components/DnsViewerPanel'
|
||||||
import { apiGet } from '../../lib/api'
|
import { apiGet } from '../../lib/api'
|
||||||
import { getToken, isLoggedIn, loginDesk } from '../../lib/auth'
|
import { canReadDnsViewer, clearSession, getToken, getUser, isLoggedIn } from '../../lib/auth'
|
||||||
|
|
||||||
export default function AdminDominio() {
|
export default function AdminDominio() {
|
||||||
const [params, setParams] = useSearchParams()
|
const [params, setParams] = useSearchParams()
|
||||||
|
|
@ -10,16 +10,19 @@ export default function AdminDominio() {
|
||||||
const [data, setData] = useState(null)
|
const [data, setData] = useState(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [username, setUsername] = useState('')
|
const user = getUser()
|
||||||
const [password, setPassword] = useState('')
|
const canRead = user && canReadDnsViewer(user.role)
|
||||||
const [loginError, setLoginError] = useState('')
|
|
||||||
|
|
||||||
const loadDns = useCallback(async (dom) => {
|
const loadDns = useCallback(async (dom) => {
|
||||||
const d = (dom || '').trim().toLowerCase()
|
const d = (dom || '').trim().toLowerCase()
|
||||||
if (!d || d.length < 3) return
|
if (!d || d.length < 3) return
|
||||||
|
if (!canReadDnsViewer(getUser()?.role)) {
|
||||||
|
setError('O seu perfil não tem permissão para consultar DNS (Spec 027).')
|
||||||
|
return
|
||||||
|
}
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError('Faça login para ver o DNS.')
|
setError('Sessão inválida — volte a entrar na área Admin.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -32,6 +35,16 @@ export default function AdminDominio() {
|
||||||
setData(payload)
|
setData(payload)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setData(null)
|
setData(null)
|
||||||
|
if (e.status === 401) {
|
||||||
|
clearSession()
|
||||||
|
setError('Sessão expirada — faça login novamente na área Admin.')
|
||||||
|
window.location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.status === 403) {
|
||||||
|
setError('Sem permissão para este domínio ou recurso DNS.')
|
||||||
|
return
|
||||||
|
}
|
||||||
setError(e.message || 'Falha ao carregar DNS')
|
setError(e.message || 'Falha ao carregar DNS')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
@ -42,20 +55,9 @@ export default function AdminDominio() {
|
||||||
const q = params.get('domain')
|
const q = params.get('domain')
|
||||||
if (q) {
|
if (q) {
|
||||||
setDomain(q)
|
setDomain(q)
|
||||||
if (isLoggedIn()) loadDns(q)
|
if (isLoggedIn() && canRead) loadDns(q)
|
||||||
}
|
|
||||||
}, [params, loadDns])
|
|
||||||
|
|
||||||
async function handleLogin(e) {
|
|
||||||
e.preventDefault()
|
|
||||||
setLoginError('')
|
|
||||||
try {
|
|
||||||
await loginDesk(username, password)
|
|
||||||
if (domain) loadDns(domain)
|
|
||||||
} catch (err) {
|
|
||||||
setLoginError(err.message || 'Login falhou')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [params, loadDns, canRead])
|
||||||
|
|
||||||
function handleSearch(e) {
|
function handleSearch(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -63,32 +65,37 @@ export default function AdminDominio() {
|
||||||
loadDns(domain)
|
loadDns(domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!canRead) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Domínio & DNS</h2>
|
||||||
|
<p className="error-text">
|
||||||
|
O perfil <strong>{user?.role || 'desconhecido'}</strong> não inclui consulta DNS.
|
||||||
|
Peça acesso na matriz Spec 027 ou use outro menu conforme o seu role.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginTop: 0 }}>Domínio & DNS</h2>
|
<h2 style={{ marginTop: 0 }}>Domínio & DNS</h2>
|
||||||
<p style={{ color: 'var(--lb-text-secondary)', maxWidth: 640 }}>
|
<p style={{ color: 'var(--lb-text-secondary)', maxWidth: 640 }}>
|
||||||
Apontamentos read-only — Cloudflare Ligbox, OpenPanel BIND ou DNS público.
|
Apontamentos read-only — Cloudflare Ligbox, OpenPanel BIND ou DNS público.
|
||||||
Alterações via suporte ou painel externo (links abaixo).
|
Alterações via suporte ou painel externo (links abaixo, conforme o seu perfil).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!isLoggedIn() ? (
|
<form
|
||||||
<form className="card login-card" onSubmit={handleLogin} style={{ maxWidth: 360, marginBottom: '1.25rem' }}>
|
className="card domain-search"
|
||||||
<h3 style={{ marginTop: 0 }}>Login Desk</h3>
|
onSubmit={handleSearch}
|
||||||
<p className="dns-viewer-meta">Use credenciais Ligbox Ops (staff) ou gerente.</p>
|
style={{
|
||||||
<label className="form-label">
|
display: 'flex',
|
||||||
Utilizador
|
gap: '0.5rem',
|
||||||
<input className="form-input" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
|
flexWrap: 'wrap',
|
||||||
</label>
|
alignItems: 'flex-end',
|
||||||
<label className="form-label">
|
marginBottom: '1rem',
|
||||||
Senha
|
}}
|
||||||
<input type="password" className="form-input" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
|
>
|
||||||
</label>
|
|
||||||
{loginError ? <p className="error-text">{loginError}</p> : null}
|
|
||||||
<button type="submit" className="btn btn-primary">Entrar</button>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form className="card domain-search" onSubmit={handleSearch} style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: '1rem' }}>
|
|
||||||
<label className="form-label" style={{ flex: '1 1 220px', margin: 0 }}>
|
<label className="form-label" style={{ flex: '1 1 220px', margin: 0 }}>
|
||||||
Domínio
|
Domínio
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ server {
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
# SPA — React Router
|
# SPA — React Router
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
# OpenPanel VM123 — DNS público porta 53 (BIND)
|
||||||
|
|
||||||
|
**Spec:** 028 — OpenPanel CE Ligbox Re-engenharia
|
||||||
|
**Data incidente/resolução:** 2026-06-22
|
||||||
|
**Solicitante:** Roger
|
||||||
|
**Status:** ✅ Resolvido e persistido
|
||||||
|
**VM:** VM123 (`10.10.10.123`) · **IP DNS público:** `95.216.14.162:53`
|
||||||
|
|
||||||
|
> Relatório operacional completo: [`../../exec-reports/EXEC_DNS53_PROXMOX_20260622_v3_FINAL.md`](../../exec-reports/EXEC_DNS53_PROXMOX_20260622_v3_FINAL.md)
|
||||||
|
> Runbook rede Proxmox: [`../../docs/network/ORIENTACAO_DNS53_PROXMOX_20260622.md`](../../docs/network/ORIENTACAO_DNS53_PROXMOX_20260622.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Papel do OpenPanel / BIND na VM123
|
||||||
|
|
||||||
|
O OpenPanel gere zonas DNS autoritativas dos clientes Ligbox. O serviço público corre em Docker:
|
||||||
|
|
||||||
|
| Item | Valor |
|
||||||
|
|------|-------|
|
||||||
|
| Container | `openpanel_dns` |
|
||||||
|
| Imagem | `ubuntu/bind9:latest` |
|
||||||
|
| Porta host | `0.0.0.0:53` (UDP/TCP) via `docker-proxy` |
|
||||||
|
| IP LAN | `10.10.10.123` |
|
||||||
|
| Rede Docker | `root_openpanel_network` · container `172.20.0.2` |
|
||||||
|
| Tipo | **Autoritativo** (não recursivo) |
|
||||||
|
|
||||||
|
**Zonas validadas (exemplos):**
|
||||||
|
|
||||||
|
| Zona | Teste | Resultado |
|
||||||
|
|------|-------|-----------|
|
||||||
|
| `betinplace.com` | `dig @10.10.10.123 betinplace.com MX` | `10 mail.betinplace.com.` |
|
||||||
|
| `diarissima.com` | `dig @10.10.10.123 diarissima.com MX` | `0 diarissima.com.` |
|
||||||
|
| `ligbox.com.br` | `dig @10.10.10.123 ligbox.com.br NS` | registos autoritativos OK |
|
||||||
|
| `google.com` | `dig @10.10.10.123 google.com` | **REFUSED** (esperado) |
|
||||||
|
|
||||||
|
Glue Cloudflare (delegação Ligbox):
|
||||||
|
|
||||||
|
| NS | A |
|
||||||
|
|----|---|
|
||||||
|
| `ns1.ligbox.com.br` | `95.216.14.162` |
|
||||||
|
| `ns2.ligbox.com.br` | `95.216.14.162` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Arquitectura end-to-end (OpenPanel + Proxmox)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cliente / Internet
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
95.216.14.162:53 ← IP público (Proxmox big1, glue DNS)
|
||||||
|
│ PREROUTING DNAT (Proxmox vmbr0)
|
||||||
|
▼
|
||||||
|
10.10.10.123:53 ← VM123 openpanel_dns (BIND Docker)
|
||||||
|
│ resposta para 10.10.10.254 (SNAT ida Proxmox)
|
||||||
|
▼
|
||||||
|
reverse NAT → cliente recebe resposta de 95.216.14.162 ✅
|
||||||
|
|
||||||
|
OpenPanel UI (:2083) ──► gere zonas ──► openpanel_dns (:53)
|
||||||
|
pfSense VM100 WAN 95.216.14.146 — NÃO é origem correcta do DNS público
|
||||||
|
```
|
||||||
|
|
||||||
|
**Importante para OpenPanel:** o painel e o BIND partilham a VM123, mas o **acesso público à porta 53** é terminado no **Proxmox** (DNAT + SNAT). Problemas de timeout externo são quase sempre **rede/NAT**, não configuração de zona no OpenPanel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Sintoma (2026-06-22)
|
||||||
|
|
||||||
|
| Observação | Detalhe |
|
||||||
|
|------------|---------|
|
||||||
|
| Externo | `nslookup betinplace.com 95.216.14.162` → **timeout** (Windows Roger) |
|
||||||
|
| LAN directo BIND | `dig @10.10.10.123` → **OK** |
|
||||||
|
| OpenPanel / zonas | Correctas no BIND |
|
||||||
|
| Causa | Resposta DNS saía com IP **95.216.14.146** (pfSense) em vez de **95.216.14.162** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Tentativas incorrectas (não repetir)
|
||||||
|
|
||||||
|
| Tentativa | Impacto OpenPanel/DNS |
|
||||||
|
|-----------|------------------------|
|
||||||
|
| DNAT Proxmox → `10.0.0.2:53` | Timeout — WAN pfSense legada inactiva |
|
||||||
|
| DNAT → `10.10.10.1:53` | Respostas Unbound LAN, não BIND OpenPanel |
|
||||||
|
| DNAT directo + `MASQUERADE -d 10.10.10.123:53` | Timeout — retorno quebrado |
|
||||||
|
| DNAT → `95.216.14.146:53` (pfSense) | Resposta com src 146, cliente descarta |
|
||||||
|
| Policy routing VM123 (Docker) | Ineficaz — tráfego BIND passa pela bridge Docker |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Solução final (activa)
|
||||||
|
|
||||||
|
### 5.1 Proxmox (`/etc/network/interfaces`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Entrada :53 → VM123 OpenPanel BIND
|
||||||
|
iptables -t nat -A PREROUTING -i vmbr0 -d 95.216.14.162 -p udp --dport 53 \
|
||||||
|
-j DNAT --to-destination 10.10.10.123:53
|
||||||
|
iptables -t nat -A PREROUTING -i vmbr0 -d 95.216.14.162 -p tcp --dport 53 \
|
||||||
|
-j DNAT --to-destination 10.10.10.123:53
|
||||||
|
|
||||||
|
# SNAT ida — permite retorno como 95.216.14.162
|
||||||
|
iptables -t nat -A POSTROUTING -d 10.10.10.123 -p udp --dport 53 ! -s 10.10.10.0/24 \
|
||||||
|
-j SNAT --to-source 10.10.10.254
|
||||||
|
iptables -t nat -A POSTROUTING -d 10.10.10.123 -p tcp --dport 53 ! -s 10.10.10.0/24 \
|
||||||
|
-j SNAT --to-source 10.10.10.254
|
||||||
|
|
||||||
|
# FORWARD retorno BIND → Internet
|
||||||
|
iptables -I FORWARD 4 -s 10.10.10.123 -p udp --sport 53 ! -d 10.10.10.0/24 -j ACCEPT
|
||||||
|
iptables -I FORWARD 5 -s 10.10.10.123 -p tcp --sport 53 ! -d 10.10.10.0/24 -j ACCEPT
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Regra removida (crítica)
|
||||||
|
|
||||||
|
Em `/etc/iptables/rules.v4` — **não reactivar**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#-A POSTROUTING -o vmbr4000 -j MASQUERADE
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 VM123 — sem alteração permanente necessária
|
||||||
|
|
||||||
|
O container `openpanel_dns` mantém-se standard. Default gateway VM123 = `10.10.10.1` (pfSense) para tráfego geral; o retorno DNS público é tratado pelo conntrack Proxmox após SNAT ida `10.10.10.254`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Validação (pós-fix)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Directo BIND OpenPanel
|
||||||
|
dig @10.10.10.123 betinplace.com MX +short
|
||||||
|
|
||||||
|
# Via IP público (como cliente externo)
|
||||||
|
dig @95.216.14.162 betinplace.com MX +short
|
||||||
|
dig @95.216.14.162 diarissima.com MX +short
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
nslookup betinplace.com 95.216.14.162
|
||||||
|
nslookup -type=mx diarissima.com 95.216.14.162
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resultado Roger (Windows 2026-06-22):** ✅ respostas recebidas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Diagnóstico rápido (OpenPanel / porta 53)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Container BIND activo?
|
||||||
|
ssh root@10.10.10.123 "docker ps --filter name=openpanel_dns"
|
||||||
|
|
||||||
|
# 2. BIND responde local?
|
||||||
|
ssh root@10.10.10.123 "dig @127.0.0.1 betinplace.com MX +short"
|
||||||
|
|
||||||
|
# 3. Tráfego externo chega?
|
||||||
|
ssh root@10.10.10.123 "timeout 10 tcpdump -ni eth0 udp port 53 -c 5"
|
||||||
|
|
||||||
|
# 4. SNAT Proxmox activo?
|
||||||
|
iptables -t nat -L POSTROUTING -n -v | grep "10.10.10.123.*53"
|
||||||
|
|
||||||
|
# 5. Resposta sai de 162 (não 146)?
|
||||||
|
tcpdump -ni vmbr4000 udp port 53 and host 10.10.10.123
|
||||||
|
```
|
||||||
|
|
||||||
|
| Sintoma | Provável causa | Acção |
|
||||||
|
|---------|----------------|-------|
|
||||||
|
| `@10.10.10.123` OK, `@162` timeout | NAT Proxmox | Rever DNAT + SNAT ida |
|
||||||
|
| Container down | OpenPanel stack | `docker restart openpanel_dns` |
|
||||||
|
| Zona wrong | OpenPanel DNS UI | Rever zona no painel, não NAT |
|
||||||
|
| REFUSED google.com | Normal | BIND autoritativo only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Pendências OpenPanel / delegação
|
||||||
|
|
||||||
|
| Item | Estado | Acção |
|
||||||
|
|------|--------|-------|
|
||||||
|
| Glue `ns1/ns2.ligbox.com.br` → 162 | ✅ | Cloudflare |
|
||||||
|
| `betinplace.com` NS no registrador | ⚠️ Pendente | Apontar para `ns1/ns2.ligbox.com.br` |
|
||||||
|
| pfSense port forward WAN :53 → 123 | Redundante | Limpeza opcional (IDs 63–66) |
|
||||||
|
| Documentação Spec 028 | ✅ | Este ficheiro |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Impacto no fluxo OpenPanel
|
||||||
|
|
||||||
|
| Fluxo | Porta 53 |
|
||||||
|
|-------|----------|
|
||||||
|
| Cliente consulta site via DNS público | Depende de `@95.216.14.162` |
|
||||||
|
| OpenPanel `domains-add` / gestão zona | Escreve no BIND local (`openpanel_dns`) |
|
||||||
|
| FOSSBilling → bridge → hosting | Não afectado directamente |
|
||||||
|
| Email / MX records | Servidos pelo mesmo BIND — validar MX após fix |
|
||||||
|
|
||||||
|
Ao provisionar **novo domínio** no OpenPanel:
|
||||||
|
|
||||||
|
1. Criar zona no painel (comportamento normal OpenPanel)
|
||||||
|
2. Validar: `dig @10.10.10.123 novo-dominio.com MX`
|
||||||
|
3. Validar público: `dig @95.216.14.162 novo-dominio.com MX`
|
||||||
|
4. No registrador: NS → `ns1.ligbox.com.br` / `ns2.ligbox.com.br`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Referências Spec 028
|
||||||
|
|
||||||
|
| Documento | Conteúdo |
|
||||||
|
|-----------|----------|
|
||||||
|
| `spec.md` | OpenPanel CE re-engenharia geral |
|
||||||
|
| `DNS53_OPENPANEL_PORTA53.md` | **Este documento** |
|
||||||
|
| `CONFIRMACAO-TESTE-API.md` | Teste API multidomínio |
|
||||||
|
| `../../exec-reports/EXEC_DNS53_PROXMOX_20260622_v3_FINAL.md` | Incidente completo Proxmox |
|
||||||
|
| `../../docs/network/ORIENTACAO_DNS53_PROXMOX_20260622.md` | Runbook rede |
|
||||||
|
| Spec **037** | DNS multi-conta Cloudflare (orquestração) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Regras de ouro (OpenPanel + DNS53)
|
||||||
|
|
||||||
|
1. DNS público Ligbox/OpenPanel = **`95.216.14.162:53`** → VM123 BIND
|
||||||
|
2. Nunca assumir pfSense WAN `10.0.0.2` — usar **`95.216.14.146`** (vmbr0)
|
||||||
|
3. SNAT ida **`10.10.10.254`** no Proxmox é **obrigatório**
|
||||||
|
4. Não usar `MASQUERADE -o vmbr4000` nem `MASQUERADE -d 10.10.10.123:53`
|
||||||
|
5. Testar zonas **autoritativas** do OpenPanel, não domínios recursivos
|
||||||
|
6. Alterar `/etc/network/interfaces` Proxmox só com backup + aprovação Roger
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Spec 028 · VM130 spec-hub · OpenPanel DNS porta 53 · 2026-06-22*
|
||||||
|
|
@ -55,6 +55,7 @@ FOSSBilling order (hosting)
|
||||||
| Patch FOSS domain | `patch-foss-openpanel-domain.sh` |
|
| Patch FOSS domain | `patch-foss-openpanel-domain.sh` |
|
||||||
| Provision manual | `provision-openpanel-hosting.sh` |
|
| Provision manual | `provision-openpanel-hosting.sh` |
|
||||||
| E2E test | `test-foss-openpanel-order.sh` |
|
| E2E test | `test-foss-openpanel-order.sh` |
|
||||||
|
| DNS BIND (público) | container `openpanel_dns` → `:53` · IP público `95.216.14.162` via Proxmox DNAT |
|
||||||
| Credenciais | `CREDENCIAIS_SERVICOS_VM123.txt` |
|
| Credenciais | `CREDENCIAIS_SERVICOS_VM123.txt` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -232,6 +233,22 @@ Script: test-foss-openpanel-order.sh (7 passos)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DNS público — porta 53 (OpenPanel BIND)
|
||||||
|
|
||||||
|
**Status (2026-06-22):** ✅ Resolvido — timeout externo corrigido no Proxmox (SNAT ida `10.10.10.254`).
|
||||||
|
|
||||||
|
| Item | Valor |
|
||||||
|
|------|-------|
|
||||||
|
| Container | `openpanel_dns` (BIND9) |
|
||||||
|
| LAN | `10.10.10.123:53` |
|
||||||
|
| IP público | `95.216.14.162:53` (glue `ns1/ns2.ligbox.com.br`) |
|
||||||
|
| Documentação | [DNS53_OPENPANEL_PORTA53.md](DNS53_OPENPANEL_PORTA53.md) |
|
||||||
|
|
||||||
|
**Teste rápido:** `dig @95.216.14.162 betinplace.com MX +short`
|
||||||
|
|
||||||
## URLs públicas
|
## URLs públicas
|
||||||
|
|
||||||
| Serviço | URL |
|
| Serviço | URL |
|
||||||
|
|
@ -271,3 +288,4 @@ specs/028-openpanel-ce-ligbox-reengineering/
|
||||||
- [x] Teste de confirmação API multidomínio — [CONFIRMACAO-TESTE-API.md](CONFIRMACAO-TESTE-API.md)
|
- [x] Teste de confirmação API multidomínio — [CONFIRMACAO-TESTE-API.md](CONFIRMACAO-TESTE-API.md)
|
||||||
- [ ] Monitorização RAM/containers por N users
|
- [ ] Monitorização RAM/containers por N users
|
||||||
- [x] Documentar no portal Spec Hub VM130
|
- [x] Documentar no portal Spec Hub VM130
|
||||||
|
- [x] Documentar DNS53 OpenPanel porta 53 — [DNS53_OPENPANEL_PORTA53.md](DNS53_OPENPANEL_PORTA53.md)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,304 @@
|
||||||
|
# Spec 034-UI — Visão processo Console (Carbonio + Nextcloud)
|
||||||
|
|
||||||
|
**Criado:** 2026-06-21
|
||||||
|
**Solicitado por:** Roger
|
||||||
|
**Status:** 📋 Draft — design aprovado conceptualmente
|
||||||
|
**URL:** `https://console.ligbox.com.br` (shell Spec [035-UX](../035-ligbox-mail-bundles-foss-openpanel/ligbox-console-shell.md))
|
||||||
|
**Referência layout:** Spec **030** (Mission Board) · Spec **035** (bundles, toggle Mail NC)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Objectivo
|
||||||
|
|
||||||
|
Documentar **como a visão de processo** da Spec 034 (mail hot + Files warm + provision NC-1→NC-5) aparece no **Ligbox Console** — sem expor Carbonio, Nextcloud ou OCS ao utilizador.
|
||||||
|
|
||||||
|
**Princípio Roger:** mesma shell Spec 030 (status bar · 3 colunas · cards · contexto), **componentes novos** para quotas duplas e pipeline de provisionamento.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Avaliação Spec 030 como referência
|
||||||
|
|
||||||
|
| Área | Nota | Decisão |
|
||||||
|
|------|------|---------|
|
||||||
|
| Shell 3 colunas + status bar | 8/10 | ✅ Herdar directo |
|
||||||
|
| Staff ops / health / falhas NC | 8/10 | ✅ Mission Board para incidentes |
|
||||||
|
| Gerente: lista contas | 6/10 | ⚠️ Adaptar cards |
|
||||||
|
| Pipeline NC-1→NC-5 | 4/10 | ➕ Componente **`ProvisionPipeline`** novo |
|
||||||
|
| Quotas mail + Files | 5/10 | ➕ Componente **`QuotaDualBar`** novo |
|
||||||
|
| Forms CRUD (criar conta) | 4/10 | ➕ Modal / context panel com forms |
|
||||||
|
|
||||||
|
**Conclusão:** Spec 030 = **chassis**; Spec 034-UI = **indicadores e fluxos** do motor híbrido.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Vocabulário UI (nunca mostrar motores)
|
||||||
|
|
||||||
|
| Backend | UI Ligbox Console |
|
||||||
|
|---------|-------------------|
|
||||||
|
| Carbonio VM112 | **Email** · quota **caixa** |
|
||||||
|
| Nextcloud VM116 Files | **Files** · quota **armazenamento** |
|
||||||
|
| OCS Provisioning API | *(invisível)* — «A criar Files…» |
|
||||||
|
| NC-1…NC-5 | **`ProvisionPipeline`** — passos humanos |
|
||||||
|
| Hot / Warm tier | **`StorageTierBadge`** — «Caixa» / «Files» |
|
||||||
|
| `nextcloud_mail_enabled` | Toggle **«Mail no Nextcloud»** |
|
||||||
|
| VM116 health | **«Armazenamento»** ● online / offline |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Processos Spec 034 → ecrãs Console
|
||||||
|
|
||||||
|
### 4.1 Mapa processo → rota
|
||||||
|
|
||||||
|
| Processo Spec 034 | Role | Rota Console | Componente principal |
|
||||||
|
|-------------------|------|--------------|----------------------|
|
||||||
|
| Onboarding conta (Carbonio + NC) | Gerente | `/admin/contas` | `ProvisionPipeline` + modal criar |
|
||||||
|
| Quotas duplas por conta | Gerente | `/admin/contas` | `QuotaDualBar` no card + context |
|
||||||
|
| Toggle Mail Nextcloud domínio | Gerente | `/admin/files` | `DomainFilesToggle` |
|
||||||
|
| Ver uso domínio mail + Files | Gerente | `/admin` Início | Status bar + cards resumo |
|
||||||
|
| Health VM116 + VM112 | Staff | `/ops` ou `/comercial/dominios` | `StackHealthTile` |
|
||||||
|
| Falha provision OCS | Staff | `/ops` Mission Board | `IncidentCard` (Spec 030) |
|
||||||
|
| Provision manual ops | Staff | `/ops` context | Form + `ProvisionPipeline` |
|
||||||
|
| Purge domínio (017+034) | Staff | `/ops` runbook | Fora UI 034 — link runbook |
|
||||||
|
| Offload anexo grande | Utilizador | webmail Carbonio | Banner «Guarde em Files» — **não** Console |
|
||||||
|
|
||||||
|
### 4.2 Pipeline NC-1 → NC-5 (componente novo)
|
||||||
|
|
||||||
|
Backend Spec 034 §Integração VM112:
|
||||||
|
|
||||||
|
```
|
||||||
|
NC-1 Criar user Nextcloud (OCS)
|
||||||
|
NC-2 Quota Files conforme plano
|
||||||
|
NC-3 Mail app pré-config (se toggle ON)
|
||||||
|
NC-4 Registar estado domínio
|
||||||
|
NC-5 Webhook Desk provisioned
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI `ProvisionPipeline`** — barra horizontal de passos:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Criando vendas@empresa.com.br │
|
||||||
|
│ │
|
||||||
|
│ ● Email ─── ● Files ─── ○ Mail NC ─── ○ Finalizar │
|
||||||
|
│ OK OK (skip) … │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Estado | Visual | Mensagem PT-BR |
|
||||||
|
|--------|--------|----------------|
|
||||||
|
| `pending` | ○ cinza | «Aguardando…» |
|
||||||
|
| `running` | ◐ animado | «A criar Files…» |
|
||||||
|
| `ok` | ● verde | «Pronto» |
|
||||||
|
| `skipped` | ○ tracejado | «Não activo» (NC-3 OFF) |
|
||||||
|
| `error` | ● vermelho | «Não foi possível — tentar novamente» |
|
||||||
|
|
||||||
|
**Regra:** utilizador vê **uma** barra — nunca «OCS API» ou «NC-3».
|
||||||
|
|
||||||
|
### 4.3 Quotas duplas (componente novo)
|
||||||
|
|
||||||
|
**`QuotaDualBar`** — duas barras empilhadas por conta:
|
||||||
|
|
||||||
|
```
|
||||||
|
Email (caixa) ████████░░ 24 / 30 GB
|
||||||
|
Files ███░░░░░░░ 60 / 200 GB
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | Fonte API |
|
||||||
|
|------|-----------|
|
||||||
|
| Mail usado/máx | Wizard `/api/domain-admin/accounts` |
|
||||||
|
| Files usado/máx | Desk `/api/v1/nextcloud/domains/{domain}/users` |
|
||||||
|
|
||||||
|
**`StorageTierBadge`** no card:
|
||||||
|
|
||||||
|
| Badge | Significado |
|
||||||
|
|-------|-------------|
|
||||||
|
| `Caixa` | Quota Carbonio (hot) |
|
||||||
|
| `Files` | Quota Nextcloud (warm) |
|
||||||
|
| `Arquivo` | Fase 3 S3 — futuro |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Wireframes — Gerente (`/admin`)
|
||||||
|
|
||||||
|
Shell Spec 030 adaptada · tokens Desk creme ([035-UX §5](../035-ligbox-mail-bundles-foss-openpanel/ligbox-console-shell.md)).
|
||||||
|
|
||||||
|
### 5.1 Início — `/admin`
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ STATUS BAR · Business · 12/25 contas · Certif. ✅ · Pagamento em dia │
|
||||||
|
│ Armazenamento Files ● online · 2.1 TB usados no domínio │
|
||||||
|
├──────────┬───────────────────────────────────────────┬───────────────────┤
|
||||||
|
│ NAV │ RESUMO │ CONTEXTO │
|
||||||
|
│ Início ● │ ┌─────────────┐ ┌─────────────┐ │ Próxima acção: │
|
||||||
|
│ Contas │ │ Email │ │ Files │ │ «13 vagas — │
|
||||||
|
│ Files │ │ 360 GB usado│ │ 2.1 TB usado│ │ criar conta» │
|
||||||
|
│ Plano │ │ 12 contas │ │ 12 contas │ │ [+ Nova conta] │
|
||||||
|
│ … │ └─────────────┘ └─────────────┘ │ │
|
||||||
|
└──────────┴───────────────────────────────────────────┴───────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Contas — `/admin/contas`
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ STATUS BAR · 12/25 contas · Plano Business │
|
||||||
|
├──────────┬───────────────────────────────────────────┬───────────────────┤
|
||||||
|
│ NAV │ CONTAS (cards — Spec 030 board) │ CONTEXTO │
|
||||||
|
│ Contas ● │ ┌─────────────────────────────────────┐ │ vendas@ │
|
||||||
|
│ │ │ vendas@empresa.com.br [Caixa][Files]│ │ QuotaDualBar │
|
||||||
|
│ │ │ ● activa · Email 24/30GB · Files 60/200│ │ [Editar quotas] │
|
||||||
|
│ │ └─────────────────────────────────────┘ │ [Reset senha] │
|
||||||
|
│ │ [+ Nova conta] → modal + ProvisionPipeline│ [Abrir webmail] │
|
||||||
|
│ │ │ [Abrir Files] │
|
||||||
|
└──────────┴───────────────────────────────────────────┴───────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modal criar conta:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Email: [ vendas ] @empresa.com.br
|
||||||
|
Nome: [ Vendas ]
|
||||||
|
Quota email: [ 30 GB ▼ ]
|
||||||
|
Quota Files: [ 200 GB ▼ ]
|
||||||
|
☑ Criar armazenamento Files
|
||||||
|
( Mail no Nextcloud: controlado em Files — toggle domínio )
|
||||||
|
|
||||||
|
[ Cancelar ] [ Criar ]
|
||||||
|
|
||||||
|
→ ProvisionPipeline inline até concluir
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Files — `/admin/files`
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ DomainFilesToggle │
|
||||||
|
│ ☐ Activar Mail no Nextcloud (email dentro do Files) │
|
||||||
|
│ Webmail mail.{dom} continua sempre disponível │
|
||||||
|
├──────────┬───────────────────────────────────────────┬───────────────────┤
|
||||||
|
│ NAV │ Lista contas (quota Files — espelho) │ CONTEXTO │
|
||||||
|
│ Files ● │ vendas@ · 60/200 GB · Files ✅ │ editar quota Files│
|
||||||
|
└──────────┴───────────────────────────────────────────┴───────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Ver Spec 035 §2.2.1 — default toggle **OFF**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Wireframes — Staff (`/ops`)
|
||||||
|
|
||||||
|
Mission Board Spec 030 · zona escura `agentic-ops.css` para incidentes; tiles health podem usar creme.
|
||||||
|
|
||||||
|
### 6.1 Stack health — domínio ou global
|
||||||
|
|
||||||
|
**`StackHealthTile`** (novo — tile ou status bar):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Stack domínio empresa.com.br │
|
||||||
|
│ Email (VM112) ● OK │
|
||||||
|
│ Files (VM116) ● OK │
|
||||||
|
│ Último provision há 2 min │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
API: Desk `GET /api/v1/nextcloud/health` + wizard domain status.
|
||||||
|
|
||||||
|
### 6.2 Incidentes provision (Mission Board)
|
||||||
|
|
||||||
|
Cards Spec 030 quando NC falha:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ [ALTO] Files não criado │
|
||||||
|
│ empresa.com.br · vendas@ │
|
||||||
|
│ Detectado há 6 min · 2 tentativas │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Acção: Repetir criação Files │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ [Abrir domínio] [Retry] [Ack] │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Context panel: `ProvisionPipeline` com passo em erro + log sanitizado (staff only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Componentes React (packages Console)
|
||||||
|
|
||||||
|
| Componente | Spec origem | Fase |
|
||||||
|
|------------|-------------|------|
|
||||||
|
| `AppLayout` | 030 / 035-UX | UI-0 |
|
||||||
|
| `StatusBar` | 030 | UI-0 |
|
||||||
|
| `ContextPanel` | 030 | UI-0 |
|
||||||
|
| `IncidentCard` | 030 | `/ops` |
|
||||||
|
| **`ProvisionPipeline`** | **034** | UI-034-A |
|
||||||
|
| **`QuotaDualBar`** | **034** | UI-034-A |
|
||||||
|
| **`StorageTierBadge`** | **034** | UI-034-A |
|
||||||
|
| **`DomainFilesToggle`** | **034 / 035** | UI-034-B |
|
||||||
|
| **`StackHealthTile`** | **034 / 033** | UI-034-B |
|
||||||
|
| **`AccountCard`** | 030 card + 034 badges | UI-034-A |
|
||||||
|
|
||||||
|
**Monorepo:** `ligbox-console-ui/packages/domain-admin/` — rotas `/admin/*`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. APIs consumidas (034 → UI)
|
||||||
|
|
||||||
|
| UI | Endpoint | Spec |
|
||||||
|
|----|----------|------|
|
||||||
|
| QuotaDualBar (mail) | Wizard `GET/PUT /api/domain-admin/accounts` | 010 |
|
||||||
|
| QuotaDualBar (Files) | Desk `GET .../nextcloud/domains/{d}/users` | [contract](./contracts/nextcloud-provisioning-api.md) |
|
||||||
|
| ProvisionPipeline | Wizard `POST accounts` + webhook progress | 034 §NC |
|
||||||
|
| DomainFilesToggle | Wizard `PATCH .../nextcloud-mail` | 035 §2.2.1 |
|
||||||
|
| StackHealthTile | Desk `/api/v1/nextcloud/health` | 034 |
|
||||||
|
| Incident retry | Desk `POST .../nextcloud/domains/{d}/users` | 034 |
|
||||||
|
|
||||||
|
**Eventos websocket/poll (fase 2):** `onboarding.nextcloud.provisioned` → actualiza pipeline sem reload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Fases de entrega UI-034
|
||||||
|
|
||||||
|
| Fase | Entrega | Depende infra |
|
||||||
|
|------|---------|---------------|
|
||||||
|
| **UI-034-A** | `QuotaDualBar` + `AccountCard` + modal criar + `ProvisionPipeline` mock | Wizard API (parcial OK) |
|
||||||
|
| **UI-034-B** | `DomainFilesToggle` + secção `/admin/files` | VM116 + OCS |
|
||||||
|
| **UI-034-C** | `StackHealthTile` staff + incident cards retry | Desk nextcloud routes |
|
||||||
|
| **UI-034-D** | Poll pipeline live + empty/error states | Webhooks NC-5 |
|
||||||
|
|
||||||
|
**Ordem Roger:** UI-034-A integrada na **UX-A** gerente ([035 tasks](../035-ligbox-mail-bundles-foss-openpanel/tasks.md)) — pode mockar Files até VM116.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Critérios de aceite UI
|
||||||
|
|
||||||
|
- [ ] Gerente vê quota **Email** e **Files** na mesma conta — zero menção «Nextcloud»
|
||||||
|
- [ ] Criar conta mostra `ProvisionPipeline` com passos NC humanizados
|
||||||
|
- [ ] Toggle Mail NC domínio default OFF — Spec 035
|
||||||
|
- [ ] Falha OCS → mensagem PT-BR + retry — staff vê card Mission Board
|
||||||
|
- [ ] Layout 3 colunas Spec 030 em `/admin/contas` e `/admin/files`
|
||||||
|
- [ ] Mobile: tabs Contas | Files | Detalhe (Spec 030 mobile pattern)
|
||||||
|
- [ ] Zero regressão login `admin@{dom}` → `/admin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Fora de scope (034-UI)
|
||||||
|
|
||||||
|
- UI offload anexo no webmail Carbonio (banner link Files — fase separada)
|
||||||
|
- Purge UI (Spec 017 runbook)
|
||||||
|
- OIDC senha única (034 fase 2 auth)
|
||||||
|
- React rewrite completo Desk tickets (opcional futuro)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Documentos relacionados
|
||||||
|
|
||||||
|
| Doc | Conteúdo |
|
||||||
|
|-----|----------|
|
||||||
|
| [spec.md](./spec.md) | Arquitectura 034 |
|
||||||
|
| [035-UX shell](../035-ligbox-mail-bundles-foss-openpanel/ligbox-console-shell.md) | Console unificado |
|
||||||
|
| [035 domain-manager-ui](../035-ligbox-mail-bundles-foss-openpanel/domain-manager-console-ui.md) | Secções `/admin` |
|
||||||
|
| [030-agentic-ops-ui](../030-agentic-ops-ui/spec.md) | Mission Board referência |
|
||||||
|
| [nextcloud-provisioning-api.md](./contracts/nextcloud-provisioning-api.md) | Contrato API |
|
||||||
|
|
@ -155,7 +155,7 @@ Após provision OK:
|
||||||
|------|------|-----------|
|
|------|------|-----------|
|
||||||
| 404 | `domain_not_found` | Domínio não orquestrado VM112 |
|
| 404 | `domain_not_found` | Domínio não orquestrado VM112 |
|
||||||
| 409 | `user_exists` | User NC ou Carbonio já existe |
|
| 409 | `user_exists` | User NC ou Carbonio já existe |
|
||||||
| 502 | `nextcloud_unreachable` | VM116 down |
|
| 502 | `nextcloud_unreachable` | VM124 down |
|
||||||
| 503 | `feature_disabled` | `NEXTCLOUD_INTEGRATION=0` |
|
| 503 | `feature_disabled` | `NEXTCLOUD_INTEGRATION=0` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -164,7 +164,7 @@ Após provision OK:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
NEXTCLOUD_INTEGRATION=0
|
NEXTCLOUD_INTEGRATION=0
|
||||||
NEXTCLOUD_OCS_URL=https://10.10.10.116
|
NEXTCLOUD_OCS_URL=https://10.10.10.124
|
||||||
NEXTCLOUD_ADMIN_USER=ligbox-provisioner
|
NEXTCLOUD_ADMIN_USER=ligbox-provisioner
|
||||||
NEXTCLOUD_ADMIN_APP_PASSWORD=***
|
NEXTCLOUD_ADMIN_APP_PASSWORD=***
|
||||||
NEXTCLOUD_DEFAULT_QUOTA=25GB
|
NEXTCLOUD_DEFAULT_QUOTA=25GB
|
||||||
|
|
|
||||||
|
|
@ -11,32 +11,35 @@ Internet (95.216.14.146)
|
||||||
│
|
│
|
||||||
├── pfSense NAT
|
├── pfSense NAT
|
||||||
│ ├── :2512 → VM112:22 (mail/wizard)
|
│ ├── :2512 → VM112:22 (mail/wizard)
|
||||||
│ ├── :2516 → VM116:22 (nextcloud) ← reservar
|
│ ├── :2516 → VM116:22 (RustDesk — não Nextcloud)
|
||||||
|
│ ├── :2524 → VM124:22 (nextcloud) ← reservar
|
||||||
│ └── :443 → CT114 Traefik
|
│ └── :443 → CT114 Traefik
|
||||||
│
|
│
|
||||||
└── CT114 (10.10.10.114)
|
└── CT114 (10.10.10.114)
|
||||||
├── mail.{dominio} → VM112:443
|
├── mail.{dominio} → VM112:443
|
||||||
├── onboard.ligbox.com.br → VM112:8090
|
├── onboard.ligbox.com.br → VM112:8090
|
||||||
├── files.{dominio} → VM116:443 ← novo router
|
├── files.{dominio} → VM124:8080 ← novo router
|
||||||
└── cloud.ligbox.com.br → VM116:443 ← hub Ligbox
|
└── cloud.ligbox.com.br → VM124:8080 ← hub Ligbox
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## VM116 — Nextcloud Hub (nova)
|
## VM124 — Nextcloud Hub (nova)
|
||||||
|
|
||||||
| Parâmetro | Valor proposto |
|
| Parâmetro | Valor |
|
||||||
|-----------|----------------|
|
|-----------|-------|
|
||||||
| **VMID** | 116 |
|
| **VMID** | 124 |
|
||||||
| **Nome** | `vm116-nextcloud-ligbox` |
|
| **Nome** | `vm124-nextcloud-ligbox` |
|
||||||
| **IP LAN** | `10.10.10.116/24` |
|
| **IP LAN** | `10.10.10.124/24` |
|
||||||
| **Gateway** | `10.10.10.1` (pfSense) |
|
| **Gateway** | `10.10.10.1` (pfSense) |
|
||||||
| **SSH WAN** | `95.216.14.146:2516` |
|
| **SSH WAN** | `95.216.14.146:2524` |
|
||||||
| **vCPU** | 4 |
|
| **vCPU** | **2** |
|
||||||
| **RAM** | 8192 MB |
|
| **RAM** | **4096 MB** |
|
||||||
| **Disco** | 500 GB (`local-lvm` ou volume dedicado) |
|
| **Disco** | **500 GB** (`local-lvm` se houver espaço) |
|
||||||
| **SO** | Ubuntu 24.04 LTS |
|
| **SO** | Ubuntu 24.04 LTS |
|
||||||
|
|
||||||
|
> **VM116** (`10.10.10.116`) = RustDesk LXC — **não** usar para Nextcloud.
|
||||||
|
|
||||||
### Serviços
|
### Serviços
|
||||||
|
|
||||||
| Serviço | Porta | Notas |
|
| Serviço | Porta | Notas |
|
||||||
|
|
@ -75,16 +78,16 @@ deploy/vm116-nextcloud/
|
||||||
| Carbonio SMTP | `:25` / submission |
|
| Carbonio SMTP | `:25` / submission |
|
||||||
| Wizard API | `:8090` |
|
| Wizard API | `:8090` |
|
||||||
|
|
||||||
### Conectividade VM116 → VM112
|
### Conectividade VM124 → VM112
|
||||||
|
|
||||||
| Origem | Destino | Porta | Uso |
|
| Origem | Destino | Porta | Uso |
|
||||||
|--------|---------|-------|-----|
|
|--------|---------|-------|-----|
|
||||||
| VM116 (Mail app users) | VM112 | 993 | IMAP SSL |
|
| VM124 (Mail app users) | VM112 | 993 | IMAP SSL |
|
||||||
| VM116 (Mail app users) | VM112 | 587/465 | SMTP submission |
|
| VM124 (Mail app users) | VM112 | 587/465 | SMTP submission |
|
||||||
| VM116 (provision script) | VM112 | 8090 | Opcional — estado domínio |
|
| VM124 (provision script) | VM112 | 8090 | Opcional — estado domínio |
|
||||||
| VM112 (wizard worker) | VM116 | 443 | OCS Provisioning API |
|
| VM112 (wizard worker) | VM124 | 443/8080 | OCS Provisioning API |
|
||||||
|
|
||||||
**Firewall pfSense:** permitir LAN `10.10.10.116 → 10.10.10.112:993,587,8090`.
|
**Firewall pfSense:** permitir LAN `10.10.10.124 → 10.10.10.112:993,587,8090`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -95,18 +98,18 @@ deploy/vm116-nextcloud/
|
||||||
Seguir padrão Spec 017 (`mail.{domain}`):
|
Seguir padrão Spec 017 (`mail.{domain}`):
|
||||||
|
|
||||||
1. Adicionar `files.{dom}` em `sni_vm112.lst` → **renomear** para `sni_tenant_apps.lst` (mail + files)
|
1. Adicionar `files.{dom}` em `sni_vm112.lst` → **renomear** para `sni_tenant_apps.lst` (mail + files)
|
||||||
2. Router Traefik `files-*-Router` → backend `http://10.10.10.116:443`
|
2. Router Traefik `files-*-Router` → backend `http://10.10.10.124:8080`
|
||||||
3. Certbot SAN multi-dom ou cert por domínio (mesmo fluxo mail)
|
3. Certbot SAN multi-dom ou cert por domínio (mesmo fluxo mail)
|
||||||
|
|
||||||
### Router hub
|
### Router hub
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# cloud.ligbox.com.br → VM116
|
# cloud.ligbox.com.br → VM124
|
||||||
http:
|
http:
|
||||||
routers:
|
routers:
|
||||||
nextcloud-hub:
|
nextcloud-hub:
|
||||||
rule: Host(`cloud.ligbox.com.br`)
|
rule: Host(`cloud.ligbox.com.br`)
|
||||||
service: nextcloud-vm116
|
service: nextcloud-vm124
|
||||||
tls:
|
tls:
|
||||||
certResolver: letsencrypt
|
certResolver: letsencrypt
|
||||||
```
|
```
|
||||||
|
|
@ -129,7 +132,7 @@ http:
|
||||||
```
|
```
|
||||||
VM112 Carbonio
|
VM112 Carbonio
|
||||||
└── volume secondary (Custom S3)
|
└── volume secondary (Custom S3)
|
||||||
└── MinIO VM116 :9000
|
└── MinIO VM124 :9000
|
||||||
bucket: carbonio-archive-{tenant}
|
bucket: carbonio-archive-{tenant}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -141,12 +144,12 @@ Credenciais S3 em `/opt/zextras/conf/` (VM112) — **nunca** no repo Git.
|
||||||
|
|
||||||
| Componente | Método | Frequência |
|
| Componente | Método | Frequência |
|
||||||
|------------|--------|------------|
|
|------------|--------|------------|
|
||||||
| VM116 snapshot | Proxmox | diário |
|
| VM124 snapshot | Proxmox | diário |
|
||||||
| Nextcloud data | `occ files:scan` + rsync/borg | diário |
|
| Nextcloud data | `occ files:scan` + rsync/borg | diário |
|
||||||
| PostgreSQL NC | `pg_dump` | diário |
|
| PostgreSQL NC | `pg_dump` | diário |
|
||||||
| MinIO (fase 2) | replica ou snapshot volume | diário |
|
| MinIO (fase 2) | replica ou snapshot volume | diário |
|
||||||
|
|
||||||
**RPO alvo:** 24 h · **RTO alvo:** 4 h (restaurar VM116 + re-ligar Traefik).
|
**RPO alvo:** 24 h · **RTO alvo:** 4 h (restaurar VM124 + re-ligar Traefik).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -154,7 +157,7 @@ Credenciais S3 em `/opt/zextras/conf/` (VM112) — **nunca** no repo Git.
|
||||||
|
|
||||||
| Métrica | Alerta |
|
| Métrica | Alerta |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Disco VM116 > 80% | ops_lead email |
|
| Disco VM124 > 80% | ops_lead email |
|
||||||
| Disco VM112 > 85% | crítico — revisar quotas hot |
|
| Disco VM112 > 85% | crítico — revisar quotas hot |
|
||||||
| Nextcloud status.php != 200 | Infra stack health Desk |
|
| Nextcloud status.php != 200 | Infra stack health Desk |
|
||||||
| IMAP latency VM112 > 2s | investigar carga Mail app |
|
| IMAP latency VM112 > 2s | investigar carga Mail app |
|
||||||
|
|
@ -165,8 +168,8 @@ Credenciais S3 em `/opt/zextras/conf/` (VM112) — **nunca** no repo Git.
|
||||||
|
|
||||||
| Opção | Prós | Contras |
|
| Opção | Prós | Contras |
|
||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
| **VM116 dedicada** (proposto) | Disco isolado, escala independente | Nova VM para gerir |
|
| **VM124 dedicada** (Roger 2026-06-21) | Disco isolado · 2 vCPU 4 GB | Nova VM após VM123 |
|
||||||
| Nextcloud na VM112 | Menos VMs | Piora problema de disco |
|
| Nextcloud na VM112 | Menos VMs | Piora problema de disco |
|
||||||
| Nextcloud na VM123 | Já tem stack Docker | Mistura finance/hosting com mail |
|
| Nextcloud na VM123 | Já tem stack Docker | Mistura finance/hosting com mail |
|
||||||
|
|
||||||
**Recomendação Spec 034:** VM116 dedicada.
|
**Decisão Spec 034:** VM124 dedicada (`10.10.10.124`).
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Quickstart — Piloto Nextcloud + Carbonio VM112 (Spec 034)
|
# Quickstart — Piloto Nextcloud + Carbonio VM112 (Spec 034)
|
||||||
|
|
||||||
**Público:** ops_lead, super_admin
|
**Público:** ops_lead, super_admin
|
||||||
**Pré-requisito:** VM116 provisionada · Carbonio VM112 operacional · acesso CT114
|
**Pré-requisito:** VM124 provisionada · Carbonio VM112 operacional · acesso CT114
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -26,10 +26,11 @@ openssl s_client -connect 127.0.0.1:993 -quiet </dev/null 2>&1 | head -3
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Instalar Nextcloud VM116 (piloto)
|
## 2. Instalar Nextcloud VM124 (piloto)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh root@10.10.10.116
|
ssh root@10.10.10.124
|
||||||
|
# ou externo: ssh root@95.216.14.146 -p 2524
|
||||||
|
|
||||||
# Após clone repo + deploy/vm116-nextcloud/
|
# Após clone repo + deploy/vm116-nextcloud/
|
||||||
cd /opt/ligbox-ops-platform/deploy/vm116-nextcloud
|
cd /opt/ligbox-ops-platform/deploy/vm116-nextcloud
|
||||||
|
|
@ -87,7 +88,7 @@ ssh root@10.10.10.112 "zmprov gq admin@DOMINIO-TESTE.com"
|
||||||
|
|
||||||
## 6. Traefik — files.{dominio} (opcional piloto)
|
## 6. Traefik — files.{dominio} (opcional piloto)
|
||||||
|
|
||||||
No CT114, seguir template Spec 017 para `mail.{dom}` substituindo backend por `10.10.10.116:443`.
|
No CT114, seguir template Spec 017 para `mail.{dom}` substituindo backend por `10.10.10.124:8080`.
|
||||||
|
|
||||||
Teste:
|
Teste:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
**Solicitado por:** Roger
|
**Solicitado por:** Roger
|
||||||
**Status:** 📋 Draft — aguarda validação arquitectura
|
**Status:** 📋 Draft — aguarda validação arquitectura
|
||||||
**Prioridade:** P1 (capacidade mail tenants + produto Ligbox Mail)
|
**Prioridade:** P1 (capacidade mail tenants + produto Ligbox Mail)
|
||||||
**Sistema:** VM112 (Carbonio) + **VM116** (Nextcloud — proposto) + CT114 (Traefik) + Desk VM122
|
**Sistema:** VM112 (Carbonio) + **VM124** (Nextcloud) + CT114 (Traefik) + Desk VM122
|
||||||
**Relacionado:** Spec **017** (purge domínio) · **022** (Carbonio release) · **013/019** (migração mail) · **027** (RBAC) · **033** (Infra stack health)
|
**Relacionado:** Spec **017** (purge domínio) · **022** (Carbonio release) · **013/019** (migração mail) · **027** (RBAC) · **033** (Infra stack health)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -20,7 +20,7 @@ Integrar **Nextcloud Hub** ao ecossistema **Ligbox Mail (VM112)** para **ampliar
|
||||||
| Camada | Produto | Função | Quota típica |
|
| Camada | Produto | Função | Quota típica |
|
||||||
|--------|---------|--------|--------------|
|
|--------|---------|--------|--------------|
|
||||||
| **Hot** | Carbonio VM112 | SMTP/IMAP, webmail, calendário, contactos | 5–10 GB / caixa |
|
| **Hot** | Carbonio VM112 | SMTP/IMAP, webmail, calendário, contactos | 5–10 GB / caixa |
|
||||||
| **Warm** | Nextcloud Files VM116 | Anexos grandes, documentos, partilha | 50–500 GB / utilizador |
|
| **Warm** | Nextcloud Files VM124 | Anexos grandes, documentos, partilha | 50–500 GB / utilizador |
|
||||||
| **Cold** (fase 2) | Carbonio → S3 secundário | Arquivo IMAP antigo, blobs offload | Configurável |
|
| **Cold** (fase 2) | Carbonio → S3 secundário | Arquivo IMAP antigo, blobs offload | Configurável |
|
||||||
|
|
||||||
O Carbonio **permanece autoridade** para `@dominio` (MX, DKIM, LMTP). Nextcloud **complementa** capacidade de armazenamento e UX unificada (Mail app + Files).
|
O Carbonio **permanece autoridade** para `@dominio` (MX, DKIM, LMTP). Nextcloud **complementa** capacidade de armazenamento e UX unificada (Mail app + Files).
|
||||||
|
|
@ -54,14 +54,14 @@ O Carbonio **permanece autoridade** para `@dominio` (MX, DKIM, LMTP). Nextcloud
|
||||||
|---|------|------------------|
|
|---|------|------------------|
|
||||||
| 1 | Motor de mail | **Carbonio VM112** — inalterado como MTA/MUA principal |
|
| 1 | Motor de mail | **Carbonio VM112** — inalterado como MTA/MUA principal |
|
||||||
| 2 | Armazenamento expandido | **Nextcloud Hub** em VM dedicada com disco ampliado |
|
| 2 | Armazenamento expandido | **Nextcloud Hub** em VM dedicada com disco ampliado |
|
||||||
| 3 | VM alvo Nextcloud | **VM116** `10.10.10.116` (nova) — 4 vCPU, 8 GB RAM, **500 GB+** disco |
|
| 3 | VM alvo Nextcloud | **VM124** `10.10.10.124` — **2 vCPU**, **4 GB RAM**, **500 GB** disco |
|
||||||
| 4 | Identidade | Conta Nextcloud = **mesmo e-mail** `@dominio` (provisionamento paralelo ao Carbonio) |
|
| 4 | Identidade | Conta Nextcloud = **mesmo e-mail** `@dominio` (provisionamento paralelo ao Carbonio) |
|
||||||
| 5 | Autenticação | Fase 1: **senha sincronizada** no wizard · Fase 2: **OIDC/SAML** ou LDAP Carbonio |
|
| 5 | Autenticação | Fase 1: **senha sincronizada** no wizard · Fase 2: **OIDC/SAML** ou LDAP Carbonio |
|
||||||
| 6 | URL tenant | `files.{dominio}` (SNI CT114) · hub Ligbox: `cloud.ligbox.com.br` |
|
| 6 | URL tenant | `files.{dominio}` (SNI CT114) · hub Ligbox: `cloud.ligbox.com.br` |
|
||||||
| 7 | Mail Nextcloud | App **Mail** → IMAP/SMTP Carbonio (`mail.{dominio}`) — **opcional** na UI |
|
| 7 | Mail Nextcloud | App **Mail** → IMAP/SMTP Carbonio (`mail.{dominio}`) — **opcional** na UI |
|
||||||
| 8 | Offload Carbonio | Fase 2: volume **S3 secundário** (MinIO na VM116) via Carbonio Storage |
|
| 8 | Offload Carbonio | Fase 2: volume **S3 secundário** (MinIO na VM124) via Carbonio Storage |
|
||||||
| 9 | Orquestração | Wizard VM112 API + Desk VM122 (monitorização, purge, RBAC) |
|
| 9 | Orquestração | Wizard VM112 API + Desk VM122 (monitorização, purge, RBAC) |
|
||||||
| 10 | Produto comercial | Pacote **Ligbox Mail Plus** = Carbonio base + Nextcloud Files quota |
|
| 10 | Produto comercial | Pacote **Ligbox Mail Plus** = Carbonio + Nextcloud — **Spec 035** (bundles FOSS/OpenPanel, preços, gerente domínio) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -84,7 +84,7 @@ flowchart TB
|
||||||
DA[Domain Admin]
|
DA[Domain Admin]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph VM116["VM116 — Nextcloud Hub (proposto)"]
|
subgraph VM124["VM124 — Nextcloud Hub"]
|
||||||
NC[Nextcloud :443]
|
NC[Nextcloud :443]
|
||||||
NCMAIL[App Mail → IMAP Carbonio]
|
NCMAIL[App Mail → IMAP Carbonio]
|
||||||
NCFiles[App Files — quota ampliada]
|
NCFiles[App Files — quota ampliada]
|
||||||
|
|
@ -140,7 +140,7 @@ Após `POST /api/onboarding/account/create` (Carbonio OK):
|
||||||
|-------|--------|
|
|-------|--------|
|
||||||
| NC-1 | Criar utilizador Nextcloud via **OCS Provisioning API** |
|
| NC-1 | Criar utilizador Nextcloud via **OCS Provisioning API** |
|
||||||
| NC-2 | Definir quota Files conforme plano |
|
| NC-2 | Definir quota Files conforme plano |
|
||||||
| NC-3 | Pré-configurar conta Mail app (IMAP host, SSL, e-mail) — **opcional** |
|
| NC-3 | Pré-configurar conta Mail app (IMAP host, SSL, e-mail) — **só se** `nextcloud_mail_enabled=1` no domínio |
|
||||||
| NC-4 | Registar `nextcloud_user_id` + `files_url` no estado do domínio |
|
| NC-4 | Registar `nextcloud_user_id` + `files_url` no estado do domínio |
|
||||||
| NC-5 | Webhook Desk `onboarding.nextcloud.provisioned` |
|
| NC-5 | Webhook Desk `onboarding.nextcloud.provisioned` |
|
||||||
|
|
||||||
|
|
@ -148,10 +148,14 @@ Após `POST /api/onboarding/account/create` (Carbonio OK):
|
||||||
|
|
||||||
### Domain Admin (`/admin`)
|
### Domain Admin (`/admin`)
|
||||||
|
|
||||||
|
UI detalhada: [034-console-process-ui.md](./034-console-process-ui.md) · Shell: [035-UX](../035-ligbox-mail-bundles-foss-openpanel/ligbox-console-shell.md)
|
||||||
|
|
||||||
| Funcionalidade | Descrição |
|
| Funcionalidade | Descrição |
|
||||||
|----------------|-----------|
|
|----------------|-----------|
|
||||||
| Ver quota Carbonio + Files | Painel unificado |
|
| Ver quota Carbonio + Files | Painel unificado |
|
||||||
|
| **Toggle Mail Nextcloud** | Activar/desactivar Mail app **por domínio** (default OFF — Spec 035 §2.2.1) |
|
||||||
| Link «Abrir Files» | Redirect `files.{dominio}` |
|
| Link «Abrir Files» | Redirect `files.{dominio}` |
|
||||||
|
| Link «Abrir webmail» | Redirect `mail.{dominio}` (sempre) |
|
||||||
| Reset senha | Sincroniza Carbonio + Nextcloud (fase 1) |
|
| Reset senha | Sincroniza Carbonio + Nextcloud (fase 1) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -175,18 +179,18 @@ Após `POST /api/onboarding/account/create` (Carbonio OK):
|
||||||
| GET | `/api/v1/nextcloud/domains/{domain}/users` | Lista utilizadores tenant |
|
| GET | `/api/v1/nextcloud/domains/{domain}/users` | Lista utilizadores tenant |
|
||||||
| POST | `/api/v1/nextcloud/domains/{domain}/users` | Provision manual (ops) |
|
| POST | `/api/v1/nextcloud/domains/{domain}/users` | Provision manual (ops) |
|
||||||
| DELETE | `/api/v1/nextcloud/domains/{domain}/users/{email}` | Remover user |
|
| DELETE | `/api/v1/nextcloud/domains/{domain}/users/{email}` | Remover user |
|
||||||
| GET | `/api/v1/nextcloud/health` | Probe VM116 |
|
| GET | `/api/v1/nextcloud/health` | Probe VM124 |
|
||||||
|
|
||||||
Ver contrato: [contracts/nextcloud-provisioning-api.md](./contracts/nextcloud-provisioning-api.md).
|
Ver contrato: [contracts/nextcloud-provisioning-api.md](./contracts/nextcloud-provisioning-api.md).
|
||||||
|
|
||||||
### Stack health (Spec 033)
|
### Stack health (Spec 033)
|
||||||
|
|
||||||
Adicionar probe VM116 em `stack_health.py`:
|
Adicionar probe VM124 em `stack_health.py`:
|
||||||
|
|
||||||
| Serviço | URL | VM |
|
| Serviço | URL | VM |
|
||||||
|---------|-----|-----|
|
|---------|-----|-----|
|
||||||
| Nextcloud Hub | `https://cloud.ligbox.com.br/status.php` | 116 |
|
| Nextcloud Hub | `https://cloud.ligbox.com.br/status.php` | 116 |
|
||||||
| Nextcloud OCS | `https://10.10.10.116/ocs/v2.php/cloud/capabilities` | 116 |
|
| Nextcloud OCS | `https://10.10.10.124/ocs/v2.php/cloud/capabilities` | 124 |
|
||||||
|
|
||||||
### Purge domínio (extensão Spec 017)
|
### Purge domínio (extensão Spec 017)
|
||||||
|
|
||||||
|
|
@ -199,15 +203,16 @@ Ordem **após** purge Carbonio:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Infraestrutura VM116
|
## Infraestrutura VM124
|
||||||
|
|
||||||
Detalhe: [infrastructure.md](./infrastructure.md)
|
Detalhe: [infrastructure.md](./infrastructure.md)
|
||||||
|
|
||||||
| Item | Valor proposto |
|
| Item | Valor |
|
||||||
|------|----------------|
|
|------|-------|
|
||||||
| VMID | 116 |
|
| VMID | 124 |
|
||||||
| IP LAN | `10.10.10.116/24` |
|
| IP LAN | `10.10.10.124/24` |
|
||||||
| SSH WAN | `95.216.14.146:2516` (reservar no pfSense) |
|
| SSH WAN | `95.216.14.146:2524` (reservar no pfSense) |
|
||||||
|
| vCPU / RAM / disco | **2** / **4 GB** / **500 GB** |
|
||||||
| Hostname | `cloud.ligbox.com.br` |
|
| Hostname | `cloud.ligbox.com.br` |
|
||||||
| SO | Ubuntu 24.04 LTS |
|
| SO | Ubuntu 24.04 LTS |
|
||||||
| Stack | Nextcloud 30+ (snap ou Docker), PostgreSQL, Redis |
|
| Stack | Nextcloud 30+ (snap ou Docker), PostgreSQL, Redis |
|
||||||
|
|
@ -230,7 +235,7 @@ Binding software (matriz RBAC):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
software_group: vm112-nextcloud
|
software_group: vm112-nextcloud
|
||||||
host: VM116
|
host: VM124
|
||||||
roles:
|
roles:
|
||||||
nextcloud_admin: [super_admin, ops_lead]
|
nextcloud_admin: [super_admin, ops_lead]
|
||||||
nextcloud_read: [technician, noc]
|
nextcloud_read: [technician, noc]
|
||||||
|
|
@ -242,7 +247,7 @@ roles:
|
||||||
|
|
||||||
### Fase 0 — Piloto infra (sem wizard)
|
### Fase 0 — Piloto infra (sem wizard)
|
||||||
|
|
||||||
- [ ] Provisionar VM116 + Nextcloud + TLS
|
- [ ] Provisionar VM124 + Nextcloud + TLS
|
||||||
- [ ] Conta manual `admin@ligbox.com.br` + teste Mail app → Carbonio VM112
|
- [ ] Conta manual `admin@ligbox.com.br` + teste Mail app → Carbonio VM112
|
||||||
- [ ] Documentar quotas e limites IMAP
|
- [ ] Documentar quotas e limites IMAP
|
||||||
|
|
||||||
|
|
@ -256,12 +261,12 @@ roles:
|
||||||
### Fase 2 — Purge + quotas comerciais
|
### Fase 2 — Purge + quotas comerciais
|
||||||
|
|
||||||
- [ ] Purge Nextcloud no fluxo Spec 017
|
- [ ] Purge Nextcloud no fluxo Spec 017
|
||||||
- [ ] FOSSBilling add-on «Mail Plus»
|
- [ ] FOSSBilling add-on «Mail Plus» → ver **Spec 035** (bundles completos)
|
||||||
- [ ] Domain Admin painel unificado quota
|
- [ ] Domain Admin painel unificado quota
|
||||||
|
|
||||||
### Fase 3 — Offload S3 Carbonio
|
### Fase 3 — Offload S3 Carbonio
|
||||||
|
|
||||||
- [ ] MinIO VM116
|
- [ ] MinIO VM124
|
||||||
- [ ] Volume secundário Carbonio → S3
|
- [ ] Volume secundário Carbonio → S3
|
||||||
- [ ] Política retenção / archive IMAP
|
- [ ] Política retenção / archive IMAP
|
||||||
|
|
||||||
|
|
@ -291,7 +296,7 @@ roles:
|
||||||
|
|
||||||
## Critérios de aceitação (Fase 1)
|
## Critérios de aceitação (Fase 1)
|
||||||
|
|
||||||
1. VM116 Nextcloud acessível em `https://cloud.ligbox.com.br` com TLS válido.
|
1. VM124 Nextcloud acessível em `https://cloud.ligbox.com.br` com TLS válido.
|
||||||
2. Utilizador `admin@ligbox.com.br` lê/envia mail via Nextcloud Mail → Carbonio VM112.
|
2. Utilizador `admin@ligbox.com.br` lê/envia mail via Nextcloud Mail → Carbonio VM112.
|
||||||
3. Wizard com flag ON cria user Nextcloud após conta Carbonio.
|
3. Wizard com flag ON cria user Nextcloud após conta Carbonio.
|
||||||
4. Desk exibe quota Carbonio + Nextcloud no tile domínio (read-only).
|
4. Desk exibe quota Carbonio + Nextcloud no tile domínio (read-only).
|
||||||
|
|
@ -306,9 +311,11 @@ roles:
|
||||||
| [infrastructure.md](./infrastructure.md) | VM116, rede, Traefik, disco |
|
| [infrastructure.md](./infrastructure.md) | VM116, rede, Traefik, disco |
|
||||||
| [quickstart.md](./quickstart.md) | Piloto manual ops |
|
| [quickstart.md](./quickstart.md) | Piloto manual ops |
|
||||||
| [tasks.md](./tasks.md) | Checklist implementação |
|
| [tasks.md](./tasks.md) | Checklist implementação |
|
||||||
|
| [034-console-process-ui.md](./034-console-process-ui.md) | **Visão processo Console** (UI Spec 030 + componentes 034) |
|
||||||
| [contracts/nextcloud-provisioning-api.md](./contracts/nextcloud-provisioning-api.md) | OCS + Desk API |
|
| [contracts/nextcloud-provisioning-api.md](./contracts/nextcloud-provisioning-api.md) | OCS + Desk API |
|
||||||
| `docs/EMAIL_LIGBOX_VM112.md` | Arquitectura mail actual |
|
| `docs/EMAIL_LIGBOX_VM112.md` | Arquitectura mail actual |
|
||||||
| `docs/vms/VM112.md` | Ficha VM112 |
|
| `docs/vms/VM112.md` | Ficha VM112 |
|
||||||
|
| [Spec 035 — Bundles FOSS/OpenPanel](../035-ligbox-mail-bundles-foss-openpanel/spec.md) | Preços, FOSS, login gerente domínio |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@
|
||||||
|
|
||||||
## Fase 0 — Piloto infra
|
## Fase 0 — Piloto infra
|
||||||
|
|
||||||
- [ ] Roger aprovar VM116 (recursos Proxmox + porta SSH 2516)
|
- [x] Roger: **VM124** após VM123 (não VM118) · 2 vCPU · 4 GB · 500 GB
|
||||||
- [ ] Criar VM116 Ubuntu 24.04 · 500 GB disco
|
- [ ] Criar VM124 Ubuntu 24.04 (`scripts/proxmox-create-vm.sh` no PVE)
|
||||||
- [ ] `deploy/vm116-nextcloud/` — docker-compose + scripts
|
- [ ] pfSense DNAT SSH WAN `:2524` → `10.10.10.124:22`
|
||||||
|
- [x] `deploy/vm116-nextcloud/` — docker-compose + scripts + Traefik snippet
|
||||||
- [ ] TLS `cloud.ligbox.com.br` via Traefik CT114
|
- [ ] TLS `cloud.ligbox.com.br` via Traefik CT114
|
||||||
- [ ] Teste Mail app → Carbonio (domínio teste)
|
- [ ] Teste Mail app → Carbonio (domínio teste)
|
||||||
- [ ] Documentar resultados no quickstart
|
- [ ] Documentar resultados no quickstart
|
||||||
|
|
@ -20,20 +21,29 @@
|
||||||
- [ ] Hook pós `account/create` (flag `NEXTCLOUD_INTEGRATION`)
|
- [ ] Hook pós `account/create` (flag `NEXTCLOUD_INTEGRATION`)
|
||||||
- [ ] Webhook Desk `onboarding.nextcloud.provisioned`
|
- [ ] Webhook Desk `onboarding.nextcloud.provisioned`
|
||||||
- [ ] API Desk: `/api/v1/nextcloud/health`, `.../domains/{domain}/status`
|
- [ ] API Desk: `/api/v1/nextcloud/health`, `.../domains/{domain}/status`
|
||||||
- [ ] Stack health VM116 em `stack_health.py` (Spec 033)
|
- [ ] Stack health VM124 em `stack_health.py` (Spec 033)
|
||||||
- [ ] Traefik template `files.{domain}` (CT114)
|
- [ ] Traefik template `files.{domain}` (CT114)
|
||||||
- [ ] Módulo Desk `nextcloud-storage` (Spec 015)
|
- [ ] Módulo Desk `nextcloud-storage` (Spec 015)
|
||||||
|
|
||||||
## Fase 2 — Operações
|
## Fase 2 — Operações + UI Console
|
||||||
|
|
||||||
- [ ] Extensão purge Spec 017 — apagar users Nextcloud
|
- [ ] Extensão purge Spec 017 — apagar users Nextcloud
|
||||||
- [ ] Domain Admin — painel quota unificado
|
- [ ] Domain Admin — painel quota unificado (`QuotaDualBar` — ver 034-console-process-ui.md)
|
||||||
- [ ] FOSSBilling produto «Ligbox Mail Plus»
|
- [ ] FOSSBilling produto «Ligbox Mail Plus»
|
||||||
- [ ] Alertas disco VM112/VM116
|
- [ ] Alertas disco VM112/VM124
|
||||||
|
|
||||||
|
## Fase 2-UI — Console processo (034-UI)
|
||||||
|
|
||||||
|
Ver: [034-console-process-ui.md](./034-console-process-ui.md)
|
||||||
|
|
||||||
|
- [ ] UI-034-A: `ProvisionPipeline` + `QuotaDualBar` + `AccountCard` em `/admin/contas`
|
||||||
|
- [ ] UI-034-B: `DomainFilesToggle` em `/admin/files`
|
||||||
|
- [ ] UI-034-C: `StackHealthTile` + incident retry em `/ops`
|
||||||
|
- [ ] UI-034-D: Poll pipeline + estados erro PT-BR
|
||||||
|
|
||||||
## Fase 3 — Offload S3
|
## Fase 3 — Offload S3
|
||||||
|
|
||||||
- [ ] MinIO VM116
|
- [ ] MinIO VM124
|
||||||
- [ ] Volume secundário Carbonio → S3
|
- [ ] Volume secundário Carbonio → S3
|
||||||
- [ ] Política archive + runbook ops
|
- [ ] Política archive + runbook ops
|
||||||
|
|
||||||
|
|
@ -43,5 +53,6 @@
|
||||||
- [x] `infrastructure.md`
|
- [x] `infrastructure.md`
|
||||||
- [x] `quickstart.md`
|
- [x] `quickstart.md`
|
||||||
- [x] `contracts/nextcloud-provisioning-api.md`
|
- [x] `contracts/nextcloud-provisioning-api.md`
|
||||||
|
- [x] `034-console-process-ui.md` — visão processo Console
|
||||||
- [ ] Actualizar `docs/vms/README.md` + `VM112.md`
|
- [ ] Actualizar `docs/vms/README.md` + `VM112.md`
|
||||||
- [ ] Publicar no Spec Hub Portal (Spec 031)
|
- [ ] Publicar no Spec Hub Portal (Spec 031)
|
||||||
|
|
|
||||||
274
specs/035-ligbox-watchman/contracts/stack-services.yaml
Normal file
274
specs/035-ligbox-watchman/contracts/stack-services.yaml
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
# Ligbox Stack Services — catálogo Watchman (Spec 035)
|
||||||
|
# Fonte de verdade partilhada com Desk stack_health (Spec 033)
|
||||||
|
# Versão: 1.0 draft — 2026-06-22
|
||||||
|
|
||||||
|
version: "1.0"
|
||||||
|
defaults:
|
||||||
|
timeout_sec: 8.0
|
||||||
|
failures_before_alert: 2
|
||||||
|
alert_repeat_minutes: 15
|
||||||
|
|
||||||
|
tiers:
|
||||||
|
critical:
|
||||||
|
interval_sec: 30
|
||||||
|
standard:
|
||||||
|
interval_sec: 60
|
||||||
|
slow:
|
||||||
|
interval_sec: 120
|
||||||
|
|
||||||
|
vms:
|
||||||
|
- vm: "112"
|
||||||
|
vm_label: "VM112 · Wizard & Mail"
|
||||||
|
ip: "10.10.10.112"
|
||||||
|
services:
|
||||||
|
- id: vm112-onboard-api
|
||||||
|
title: Onboard Portal API
|
||||||
|
spec: "001"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.112:8090/api/onboarding/health"
|
||||||
|
expect_status: [200]
|
||||||
|
|
||||||
|
- id: vm112-onboard-ui
|
||||||
|
title: Onboard Wizard UI
|
||||||
|
spec: "025"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "https://onboard.ligbox.com.br/"
|
||||||
|
expect_status: [200, 301, 302, 403]
|
||||||
|
|
||||||
|
- id: vm112-carbonio
|
||||||
|
title: Carbonio EmailServer
|
||||||
|
spec: "022"
|
||||||
|
kind: sw
|
||||||
|
tier: slow
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "https://10.10.10.112/"
|
||||||
|
verify_tls: false
|
||||||
|
expect_status: [200, 301, 302, 403, 404]
|
||||||
|
|
||||||
|
- id: vm112-domain-api
|
||||||
|
title: Domain Admin API
|
||||||
|
spec: "017"
|
||||||
|
kind: api
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.112:8090/api/onboarding/health"
|
||||||
|
expect_status: [200]
|
||||||
|
|
||||||
|
- vm: "114"
|
||||||
|
vm_label: "CT114 · Edge Traefik"
|
||||||
|
ip: "10.10.10.114"
|
||||||
|
services:
|
||||||
|
- id: vm114-traefik
|
||||||
|
title: Traefik API
|
||||||
|
spec: "026"
|
||||||
|
kind: sw
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.114:8080/api/overview"
|
||||||
|
expect_status: [200, 401, 403]
|
||||||
|
|
||||||
|
- id: vm114-desk-route
|
||||||
|
title: Router Desk (WAN)
|
||||||
|
spec: "026"
|
||||||
|
kind: integration
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "https://desk.ligbox.com.br/"
|
||||||
|
expect_status: [200, 301, 302]
|
||||||
|
|
||||||
|
- id: vm114-api-route
|
||||||
|
title: Router API Ops (WAN)
|
||||||
|
spec: "027"
|
||||||
|
kind: integration
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "https://api.ops.ligbox.com.br/health"
|
||||||
|
expect_status: [200]
|
||||||
|
|
||||||
|
- vm: "122"
|
||||||
|
vm_label: "VM122 · Ops Desk"
|
||||||
|
ip: "10.10.10.122"
|
||||||
|
services:
|
||||||
|
- id: vm122-desk-api
|
||||||
|
title: Desk API (FastAPI)
|
||||||
|
spec: "003"
|
||||||
|
kind: app
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://127.0.0.1:8080/health"
|
||||||
|
expect_status: [200]
|
||||||
|
|
||||||
|
- id: vm122-desk-ui
|
||||||
|
title: Desk Frontend
|
||||||
|
spec: "033"
|
||||||
|
kind: app
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.122:8091/"
|
||||||
|
expect_status: [200, 301, 302]
|
||||||
|
|
||||||
|
- id: vm122-redis
|
||||||
|
title: Redis
|
||||||
|
spec: "—"
|
||||||
|
kind: sw
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: redis
|
||||||
|
url: "redis://127.0.0.1:6379/0"
|
||||||
|
|
||||||
|
- id: vm122-docker-frontend
|
||||||
|
title: Docker container frontend
|
||||||
|
spec: "035"
|
||||||
|
kind: sw
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: docker
|
||||||
|
container: ligbox-ops-platform_frontend_1
|
||||||
|
expect_running: true
|
||||||
|
|
||||||
|
- id: vm122-docker-api
|
||||||
|
title: Docker container api
|
||||||
|
spec: "035"
|
||||||
|
kind: sw
|
||||||
|
tier: critical
|
||||||
|
probe:
|
||||||
|
type: docker
|
||||||
|
container: ligbox-ops-platform_api_1
|
||||||
|
expect_running: true
|
||||||
|
|
||||||
|
- vm: "123"
|
||||||
|
vm_label: "VM123 · Finance & Hosting"
|
||||||
|
ip: "10.10.10.123"
|
||||||
|
services:
|
||||||
|
- id: vm123-foss
|
||||||
|
title: FOSSBilling
|
||||||
|
spec: "024"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.123:8092/"
|
||||||
|
expect_status: [200, 301, 302, 403]
|
||||||
|
|
||||||
|
- id: vm123-odoo
|
||||||
|
title: Odoo 16
|
||||||
|
spec: "024"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.123:8069/web/login"
|
||||||
|
expect_status: [200, 301, 302]
|
||||||
|
|
||||||
|
- id: vm123-openpanel-ui
|
||||||
|
title: OpenPanel CE
|
||||||
|
spec: "028"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "https://openpanel.ligbox.com.br/"
|
||||||
|
expect_status: [200, 301, 302, 403]
|
||||||
|
|
||||||
|
- id: vm123-openadmin
|
||||||
|
title: OpenAdmin
|
||||||
|
spec: "024"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "https://admin.openpanel.ligbox.com.br/"
|
||||||
|
expect_status: [200, 301, 302, 403]
|
||||||
|
|
||||||
|
- id: vm123-openpanel-bridge
|
||||||
|
title: OpenPanel FOSS Bridge
|
||||||
|
spec: "028"
|
||||||
|
kind: api
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http_bearer
|
||||||
|
url: "http://10.10.10.123:18087/api"
|
||||||
|
env_token: OPENPANEL_BRIDGE_TOKEN
|
||||||
|
expect_status: [200, 401, 403]
|
||||||
|
|
||||||
|
- id: vm123-ops-console
|
||||||
|
title: Ops Console UI
|
||||||
|
spec: "019"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.123:8100/health"
|
||||||
|
expect_status: [200]
|
||||||
|
|
||||||
|
- id: vm123-phpmyadmin
|
||||||
|
title: phpMyAdmin
|
||||||
|
spec: "—"
|
||||||
|
kind: sw
|
||||||
|
tier: slow
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.123:8888/"
|
||||||
|
expect_status: [200, 301, 302, 403]
|
||||||
|
|
||||||
|
- id: vm123-ollama
|
||||||
|
title: Ollama LLM
|
||||||
|
spec: "029"
|
||||||
|
kind: api
|
||||||
|
tier: slow
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.123:11434/api/tags"
|
||||||
|
expect_status: [200]
|
||||||
|
|
||||||
|
- vm: "130"
|
||||||
|
vm_label: "CT130 · Spec Hub"
|
||||||
|
ip: "10.10.10.130"
|
||||||
|
services:
|
||||||
|
- id: vm130-forgejo
|
||||||
|
title: Forgejo Git
|
||||||
|
spec: "031"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.130:3000/"
|
||||||
|
expect_status: [200, 301, 302, 403]
|
||||||
|
|
||||||
|
- id: vm130-spec-portal
|
||||||
|
title: Spec Portal
|
||||||
|
spec: "031"
|
||||||
|
kind: app
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "http://10.10.10.130:8080/"
|
||||||
|
expect_status: [200]
|
||||||
|
|
||||||
|
- id: vm130-spec-public
|
||||||
|
title: Spec Hub (WAN)
|
||||||
|
spec: "031"
|
||||||
|
kind: integration
|
||||||
|
tier: standard
|
||||||
|
probe:
|
||||||
|
type: http
|
||||||
|
url: "https://spec.ligbox.com.br/"
|
||||||
|
expect_status: [200, 301, 302]
|
||||||
|
|
||||||
|
# Fase 2 — descomentar quando activar
|
||||||
|
# - vm: "104"
|
||||||
|
# vm_label: "VM104 · Wazuh"
|
||||||
|
# services: [...]
|
||||||
57
specs/035-ligbox-watchman/contracts/status-snapshot.md
Normal file
57
specs/035-ligbox-watchman/contracts/status-snapshot.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Contrato — Watchman status snapshot (Spec 035)
|
||||||
|
|
||||||
|
**Ficheiro:** `/var/lib/ligbox-watchman/status.json`
|
||||||
|
**Actualização:** a cada ciclo completo do scheduler (máx. 30 s para tier critical)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"watchman_version": "1.0.0",
|
||||||
|
"generated_at": "2026-06-22T11:30:00+00:00",
|
||||||
|
"hostname": "ligbox-ops",
|
||||||
|
"summary": {
|
||||||
|
"total": 24,
|
||||||
|
"ok": 22,
|
||||||
|
"degraded": 2,
|
||||||
|
"open_incidents": 1
|
||||||
|
},
|
||||||
|
"last_alert_at": "2026-06-22T11:25:10+00:00",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"id": "vm122-desk-ui",
|
||||||
|
"vm": "122",
|
||||||
|
"title": "Desk Frontend",
|
||||||
|
"tier": "critical",
|
||||||
|
"ok": false,
|
||||||
|
"status": "down",
|
||||||
|
"consecutive_failures": 3,
|
||||||
|
"last_ok_at": "2026-06-21T16:47:00+00:00",
|
||||||
|
"last_check_at": "2026-06-22T11:30:00+00:00",
|
||||||
|
"detail": "Connection refused",
|
||||||
|
"http_status": null,
|
||||||
|
"incident_open": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Campos
|
||||||
|
|
||||||
|
| Campo | Tipo | Descrição |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| `ok` | bool | Resultado último probe |
|
||||||
|
| `status` | string | `online` \| `down` \| `check` |
|
||||||
|
| `consecutive_failures` | int | Falhas seguidas (reset em OK) |
|
||||||
|
| `incident_open` | bool | Alerta DOWN enviado, ainda não RESOLVED |
|
||||||
|
| `detail` | string | Erro ou HTTP status humanizado |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consumo Desk (Fase 2)
|
||||||
|
|
||||||
|
`GET /api/v1/watchman/status` lê este ficheiro (ou cache 5 s) — **sem auth pública**; RBAC `super_admin`, `ops_lead`, `technician`, `noc`.
|
||||||
81
specs/035-ligbox-watchman/quickstart.md
Normal file
81
specs/035-ligbox-watchman/quickstart.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# Quickstart — Ligbox Watchman (Spec 035)
|
||||||
|
|
||||||
|
**Público:** ops_lead, super_admin
|
||||||
|
**Host alvo:** VM122 (`10.10.10.122`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pré-requisitos
|
||||||
|
|
||||||
|
- Repo em `/opt/ligbox-ops-platform` (git pull Forgejo)
|
||||||
|
- Python 3.11+
|
||||||
|
- Acesso LAN a VMs 112/114/123/130
|
||||||
|
- Tópico ntfy criado (ex.: `ligbox-watchman`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Mitigação imediata (Docker restart)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/ligbox-ops-platform
|
||||||
|
# Adicionar restart: unless-stopped em api, frontend, worker, redis
|
||||||
|
docker-compose -f docker-compose.mvp.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Instalar Watchman (após implementação Fase 1)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/ligbox-ops-platform
|
||||||
|
sudo ./deploy/watchman/install.sh
|
||||||
|
sudo cp deploy/watchman/watchman.env.example /etc/ligbox-watchman/env
|
||||||
|
sudo nano /etc/ligbox-watchman/env # NTFY_URL, tópico, email
|
||||||
|
sudo systemctl enable --now ligbox-watchman
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Verificar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status ligbox-watchman
|
||||||
|
journalctl -u ligbox-watchman -f
|
||||||
|
cat /var/lib/ligbox-watchman/status.json | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Teste de alerta (staging)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop ligbox-ops-platform_frontend_1
|
||||||
|
# Esperar ~60s (2× critical 30s) → ntfy DOWN
|
||||||
|
docker start ligbox-ops-platform_frontend_1
|
||||||
|
# Esperar ~60s → ntfy RESOLVED
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variáveis (`/etc/ligbox-watchman/env`)
|
||||||
|
|
||||||
|
```env
|
||||||
|
WATCHMAN_CATALOG=/opt/ligbox-ops-platform/contracts/stack-services.yaml
|
||||||
|
WATCHMAN_STATE_DIR=/var/lib/ligbox-watchman
|
||||||
|
NTFY_URL=https://ntfy.ligbox.com.br
|
||||||
|
NTFY_TOPIC=ligbox-watchman
|
||||||
|
NTFY_TOKEN=
|
||||||
|
ALERT_EMAIL=admin@ligbox.com.br
|
||||||
|
SMTP_HOST=127.0.0.1
|
||||||
|
SMTP_PORT=25
|
||||||
|
FAILURES_BEFORE_ALERT=2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop ligbox-watchman
|
||||||
|
sudo systemctl disable ligbox-watchman
|
||||||
|
```
|
||||||
275
specs/035-ligbox-watchman/spec.md
Normal file
275
specs/035-ligbox-watchman/spec.md
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
# Spec 035 — Ligbox Watchman (vigilância contínua + alertas)
|
||||||
|
|
||||||
|
**Criado:** 2026-06-22
|
||||||
|
**Solicitado por:** Roger
|
||||||
|
**Status:** 📋 Draft — aguarda implementação Fase 1
|
||||||
|
**Prioridade:** **P0** (incidente Desk 502 sem alerta — 2026-06-22)
|
||||||
|
**Sistema:** VM122 (processo standalone) + monorepo `ligbox-ops-platform`
|
||||||
|
**Relacionado:** Spec **033** (stack health UI) · Spec **002** (Wazuh) · Spec **007** (push) · VM115 ntfy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumo
|
||||||
|
|
||||||
|
**Ligbox Watchman** é um processo **leve, contínuo e independente** do Desk API que monitoriza **todos** os serviços do stack Ligbox (VMs 112, 114, 122, 123, 130 e extensões), regista estado, e **alerta** quando um serviço deixa de responder — **antes** de um utilizador reportar “a página não abre”.
|
||||||
|
|
||||||
|
**Motivação (incidente real):** `desk.ligbox.com.br` devolveu **HTTP 502** porque `ligbox-ops-platform_frontend_1` e `api_1` estavam **Exited** na VM122 após reboot. O Traefik estava OK; o **INFRA CODE** do Desk não alertou porque depende da própria API.
|
||||||
|
|
||||||
|
**Princípio:** Watchman **não morre** quando o Desk morre.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problema
|
||||||
|
|
||||||
|
| Lacuna actual | Impacto |
|
||||||
|
|---------------|---------|
|
||||||
|
| `stack_health.py` só via API Desk | Sem alerta se API/UI caírem |
|
||||||
|
| Sem processo 24/7 dedicado | Falhas após reboot passam despercebidas |
|
||||||
|
| Docker sem `restart: unless-stopped` | Containers ficam parados (exit 255) |
|
||||||
|
| Wazuh (VM104) | SIEM — não substitui uptime sintético por URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisões de arquitectura
|
||||||
|
|
||||||
|
| # | Tema | Decisão |
|
||||||
|
|---|------|---------|
|
||||||
|
| 1 | Onde corre | **VM122** — host com LAN para 112/114/123/130; processo **fora** do container `api` |
|
||||||
|
| 2 | Catálogo | **Fonte única** YAML `contracts/stack-services.yaml`; Desk importa o mesmo catálogo (refactor Spec 033) |
|
||||||
|
| 3 | Intervalo | **Tier crítico** 30 s · **tier standard** 60 s · **tier slow** 120 s — não 1 s global (anti-ruído) |
|
||||||
|
| 4 | Anti-flap | Alerta só após **N falhas consecutivas** (default **2**) |
|
||||||
|
| 5 | Recovery | Notificação **RESOLVED** quando serviço volta OK após alerta |
|
||||||
|
| 6 | Estado | SQLite local `watchman.db` (~MB) — histórico 7 dias |
|
||||||
|
| 7 | Alertas | **ntfy** (primário) + **email** Postfix (secundário) + webhook Desk (opcional) |
|
||||||
|
| 8 | Recursos | 1 processo Python, `asyncio` + pool HTTP, RAM alvo **< 64 MB** |
|
||||||
|
| 9 | Deploy | `systemd` `ligbox-watchman.service` + opcional container Docker dedicado |
|
||||||
|
| 10 | RBAC | Sem UI própria v1 — leitura via ficheiro status JSON + integração futura Desk |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph VM122
|
||||||
|
W[watchman.py systemd]
|
||||||
|
DB[(watchman.db SQLite)]
|
||||||
|
W --> DB
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph LAN
|
||||||
|
VM112[VM112]
|
||||||
|
VM114[CT114]
|
||||||
|
VM123[VM123]
|
||||||
|
VM130[CT130]
|
||||||
|
end
|
||||||
|
|
||||||
|
W -->|HTTP/TCP probes| VM112
|
||||||
|
W -->|HTTP/TCP probes| VM114
|
||||||
|
W -->|HTTP/TCP probes| VM123
|
||||||
|
W -->|HTTP/TCP probes| VM130
|
||||||
|
W -->|HTTPS WAN| desk.ligbox.com.br
|
||||||
|
|
||||||
|
W -->|POST| ntfy[VM115 ntfy]
|
||||||
|
W -->|SMTP| postfix[Postfix Proxmox]
|
||||||
|
W -.->|opcional| desk_hook[Desk webhook]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Catálogo de serviços (v1)
|
||||||
|
|
||||||
|
Herda Spec 033 (`stack_health.py`) — **~22 probes** em 5 VMs:
|
||||||
|
|
||||||
|
| VM | Serviços |
|
||||||
|
|----|----------|
|
||||||
|
| **112** | Onboard API, Wizard UI, Carbonio, Domain Admin API |
|
||||||
|
| **114** | Traefik API, Router Desk WAN, Router API Ops WAN |
|
||||||
|
| **122** | Desk API LAN, Desk UI :8091, Redis, integrações locais |
|
||||||
|
| **123** | FOSSBilling, Odoo, OpenPanel, OpenAdmin, Bridge :18087, Ops Console, phpMyAdmin, Ollama |
|
||||||
|
| **130** | Forgejo, Spec Portal LAN, Spec Hub WAN |
|
||||||
|
|
||||||
|
**Fase 2 (extensão catálogo):** VM104 Wazuh, CT107 Fluxus, VM116 RustDesk, VM124 Nextcloud, pfSense, Proxmox host ping.
|
||||||
|
|
||||||
|
Schema: [contracts/stack-services.yaml](./contracts/stack-services.yaml).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tiers e intervalos
|
||||||
|
|
||||||
|
| Tier | Intervalo | Exemplos |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `critical` | 30 s | `desk.ligbox.com.br`, `api.ops.ligbox.com.br`, Traefik, Desk API/UI LAN |
|
||||||
|
| `standard` | 60 s | Onboard, OpenPanel, Forgejo, FOSSBilling |
|
||||||
|
| `slow` | 120 s | Ollama, phpMyAdmin, Carbonio HTTPS LAN |
|
||||||
|
|
||||||
|
Cada ciclo do Watchman executa apenas probes **due** (scheduler por `next_check_at`), não todos de uma vez cada segundo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alertas
|
||||||
|
|
||||||
|
### Canais (ordem de envio)
|
||||||
|
|
||||||
|
1. **ntfy** — `https://ntfy.ligbox.com.br/ligbox-watchman` (tópico configurável; auth opcional)
|
||||||
|
2. **Email** — `admin@ligbox.com.br` via SMTP LAN (Postfix host ou VM122)
|
||||||
|
3. **Desk** (opcional) — `POST` evento interno para tab Eventos
|
||||||
|
|
||||||
|
### Formato ntfy (DOWN)
|
||||||
|
|
||||||
|
```
|
||||||
|
Title: 🔴 DOWN vm122-desk-ui
|
||||||
|
Body: Desk Frontend — HTTP timeout (3 failures)
|
||||||
|
Tags: warning,skull
|
||||||
|
Priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
### Formato ntfy (RESOLVED)
|
||||||
|
|
||||||
|
```
|
||||||
|
Title: 🟢 OK vm122-desk-ui
|
||||||
|
Body: Desk Frontend — recovered after 19m down
|
||||||
|
Tags: white_check_mark
|
||||||
|
Priority: default
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-spam
|
||||||
|
|
||||||
|
- Máximo **1 alerta DOWN** por serviço por janela de **15 min** (repeat throttle)
|
||||||
|
- RESOLVED sempre enviado uma vez por incidente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complemento Docker (VM122)
|
||||||
|
|
||||||
|
Independentemente do Watchman, aplicar na **produção** Desk:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
em `frontend`, `api`, `worker`, `redis` no `docker-compose.mvp.yml` — previne classe de incidente 2026-06-22.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estrutura no monorepo
|
||||||
|
|
||||||
|
```
|
||||||
|
projects/integrations/watchman/
|
||||||
|
watchman.py # daemon principal
|
||||||
|
probes.py # HTTP, TCP, Redis, Docker (opcional)
|
||||||
|
catalog.py # load YAML
|
||||||
|
alerts.py # ntfy, email, desk webhook
|
||||||
|
store.py # SQLite state
|
||||||
|
requirements.txt
|
||||||
|
README.md
|
||||||
|
|
||||||
|
contracts/stack-services.yaml # catálogo partilhado (fonte de verdade)
|
||||||
|
|
||||||
|
deploy/watchman/
|
||||||
|
install.sh # systemd + venv
|
||||||
|
ligbox-watchman.service
|
||||||
|
watchman.env.example
|
||||||
|
docker-compose.watchman.yml # alternativa container
|
||||||
|
|
||||||
|
specs/035-ligbox-watchman/
|
||||||
|
spec.md
|
||||||
|
tasks.md
|
||||||
|
quickstart.md
|
||||||
|
contracts/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Refactor Spec 033:** `stack_health.py` passa a ler `contracts/stack-services.yaml` + registry de probe handlers (evita duplicação).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API / ficheiros de status (v1)
|
||||||
|
|
||||||
|
| Artefacto | Path | Descrição |
|
||||||
|
|-----------|------|-----------|
|
||||||
|
| Status JSON | `/var/lib/ligbox-watchman/status.json` | Snapshot último ciclo (para Desk/nginx) |
|
||||||
|
| SQLite | `/var/lib/ligbox-watchman/watchman.db` | Histórico incidentes |
|
||||||
|
| Log | `journalctl -u ligbox-watchman` | stdout estruturado |
|
||||||
|
|
||||||
|
Formato `status.json`: ver [contracts/status-snapshot.md](./contracts/status-snapshot.md).
|
||||||
|
|
||||||
|
**Fase 2:** `GET /api/v1/watchman/status` no Desk (read-only proxy do JSON).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RBAC e segurança
|
||||||
|
|
||||||
|
| Item | Regra |
|
||||||
|
|------|-------|
|
||||||
|
| Credenciais ntfy/SMTP | `watchman.env` — não commitar |
|
||||||
|
| Probes WAN | só URLs públicas; LAN sem secrets em logs |
|
||||||
|
| OPENPANEL_BRIDGE_TOKEN | herdado de env VM122 (mesmo que Desk) |
|
||||||
|
| Acesso status.json | `root:ligbox-watchman` ou leitura Desk via grupo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fases de implementação
|
||||||
|
|
||||||
|
### Fase 0 — Mitigação imediata (sem Watchman)
|
||||||
|
|
||||||
|
- [ ] `restart: unless-stopped` em `docker-compose.mvp.yml` produção VM122
|
||||||
|
- [ ] Documentar no quickstart
|
||||||
|
|
||||||
|
### Fase 1 — Watchman MVP
|
||||||
|
|
||||||
|
- [ ] `contracts/stack-services.yaml` extraído de `stack_health.py`
|
||||||
|
- [ ] `watchman.py` + systemd na VM122
|
||||||
|
- [ ] Alertas ntfy
|
||||||
|
- [ ] SQLite + anti-flap
|
||||||
|
- [ ] `status.json` para consulta
|
||||||
|
|
||||||
|
### Fase 2 — Integração Desk + email
|
||||||
|
|
||||||
|
- [ ] Refactor `stack_health.py` → YAML
|
||||||
|
- [ ] Email Postfix
|
||||||
|
- [ ] Card “Watchman” em INFRA CODE (último alerta)
|
||||||
|
- [ ] Catálogo VM104, 107, 116, 124
|
||||||
|
|
||||||
|
### Fase 3 — Auto-remediação (opcional)
|
||||||
|
|
||||||
|
- [ ] Probe `docker` local: se `frontend`/`api` down → `docker start` (com confirmação Roger)
|
||||||
|
- [ ] Integração Agentic Ops (Spec 029) — ticket automático
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critérios de aceitação (Fase 1)
|
||||||
|
|
||||||
|
1. Parar `ligbox-ops-platform_frontend_1` → alerta ntfy em **< 2 min** (2×30 s critical).
|
||||||
|
2. Subir frontend → alerta **RESOLVED** em **< 2 min**.
|
||||||
|
3. Parar Desk API → Watchman **continua** a alertar (processo systemd independente).
|
||||||
|
4. `status.json` actualizado a cada ciclo; `summary.degraded` correcto.
|
||||||
|
5. RAM do processo **< 64 MB** em steady state (medido 1 h).
|
||||||
|
6. Catálogo YAML versionado no Forgejo; alteração de URL = PR no repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fora de escopo (v1)
|
||||||
|
|
||||||
|
- UI web própria do Watchman (usar ntfy + Desk INFRA)
|
||||||
|
- Prometheus/Grafana (peso excessivo para P0)
|
||||||
|
- Monitorização Proxmox host metrics (Fase 2+)
|
||||||
|
- Substituição do Wazuh
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentos relacionados
|
||||||
|
|
||||||
|
| Documento | Conteúdo |
|
||||||
|
|-----------|----------|
|
||||||
|
| [quickstart.md](./quickstart.md) | Instalação VM122 |
|
||||||
|
| [tasks.md](./tasks.md) | Checklist |
|
||||||
|
| [contracts/stack-services.yaml](./contracts/stack-services.yaml) | Catálogo probes |
|
||||||
|
| [contracts/status-snapshot.md](./contracts/status-snapshot.md) | Schema JSON |
|
||||||
|
| `specs/033-desk-infra-console-ui/spec.md` | UI stack health |
|
||||||
|
| `projects/ops-desk/api/app/stack_health.py` | Implementação actual (a migrar) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Incidente de referência
|
||||||
|
|
||||||
|
**2026-06-22:** `https://desk.ligbox.com.br/` → HTTP **502** (Traefik OK, backend VM122:8091 down). Containers `Exited (255)` ~19 h. Causa provável: reboot VM sem restart Docker. **Watchman teria alertado em < 1 min** (tier critical).
|
||||||
46
specs/035-ligbox-watchman/tasks.md
Normal file
46
specs/035-ligbox-watchman/tasks.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Spec 035 — Tasks
|
||||||
|
|
||||||
|
**Status:** Draft · **Início:** 2026-06-22
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 0 — Mitigação (hoje)
|
||||||
|
|
||||||
|
- [ ] `restart: unless-stopped` em `docker-compose.mvp.yml` (api, frontend, worker, redis)
|
||||||
|
- [ ] Deploy VM122 produção
|
||||||
|
- [ ] Validar reboot test (opcional)
|
||||||
|
|
||||||
|
## Fase 1 — Watchman MVP
|
||||||
|
|
||||||
|
- [x] Spec 035 + `contracts/stack-services.yaml`
|
||||||
|
- [ ] `projects/integrations/watchman/watchman.py`
|
||||||
|
- [ ] `projects/integrations/watchman/probes.py` (http, redis, docker)
|
||||||
|
- [ ] `projects/integrations/watchman/alerts.py` (ntfy)
|
||||||
|
- [ ] `projects/integrations/watchman/store.py` (SQLite)
|
||||||
|
- [ ] `deploy/watchman/install.sh` + `ligbox-watchman.service`
|
||||||
|
- [ ] `deploy/watchman/watchman.env.example`
|
||||||
|
- [ ] Teste E2E: stop frontend → alerta → start → resolved
|
||||||
|
- [ ] Documentar no `deploy/manifest.yaml`
|
||||||
|
|
||||||
|
## Fase 2 — Integração
|
||||||
|
|
||||||
|
- [ ] Refactor `stack_health.py` → ler YAML
|
||||||
|
- [ ] Alertas email Postfix
|
||||||
|
- [ ] `GET /api/v1/watchman/status` Desk
|
||||||
|
- [ ] Card Watchman em INFRA CODE
|
||||||
|
- [ ] Extensão catálogo: VM104, CT107, VM116, VM124
|
||||||
|
|
||||||
|
## Fase 3 — Opcional
|
||||||
|
|
||||||
|
- [ ] Auto-restart Docker containers críticos (flag `WATCHMAN_REMEDIATE=0`)
|
||||||
|
- [ ] Webhook Desk → Eventos tab
|
||||||
|
- [ ] Spec Hub portal link para status
|
||||||
|
|
||||||
|
## Documentação
|
||||||
|
|
||||||
|
- [x] `specs/035-ligbox-watchman/spec.md`
|
||||||
|
- [x] `quickstart.md`
|
||||||
|
- [x] `contracts/stack-services.yaml`
|
||||||
|
- [x] `contracts/status-snapshot.md`
|
||||||
|
- [ ] Actualizar `docs/vms/README.md`
|
||||||
|
- [ ] Push Forgejo CT130
|
||||||
54
specs/036-backstage-techdocs-speckit-companion/quickstart.md
Normal file
54
specs/036-backstage-techdocs-speckit-companion/quickstart.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Quickstart — Spec 036 (Companion + Backstage)
|
||||||
|
|
||||||
|
## A) Spec Kit Companion no Cursor (Roger — 5 min)
|
||||||
|
|
||||||
|
### 1. Clone do monorepo (se ainda não tens)
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
cd C:\Users\roger
|
||||||
|
git clone https://git.spec.ligbox.com.br/ligbox/ligbox-ops-platform.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Instalar extensão
|
||||||
|
|
||||||
|
1. Cursor → **Extensions** (`Ctrl+Shift+X`)
|
||||||
|
2. Pesquisar: `SpecKit Companion`
|
||||||
|
3. Instalar: **alfredoperez.speckit-companion**
|
||||||
|
4. Reload window
|
||||||
|
|
||||||
|
### 3. Abrir projecto
|
||||||
|
|
||||||
|
1. **File → Open Folder** → `C:\Users\roger\ligbox-ops-platform`
|
||||||
|
2. Sidebar esquerda → ícone **SpecKit Companion**
|
||||||
|
3. Clicar spec **035-ligbox-watchman** (ou qualquer `specs/*/`)
|
||||||
|
|
||||||
|
### 4. O que deves ver
|
||||||
|
|
||||||
|
- Tabs: **Specify · Plan · Tasks · Done**
|
||||||
|
- Markdown renderizado + Mermaid se existir na spec
|
||||||
|
- Botões para avançar fases (integra com `/speckit.*` no chat Cursor)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B) Backstage PoC CT130 (ops — quando Fase 1)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# VM130 — após deploy compose (Fase 1)
|
||||||
|
cd /opt/ligbox-spec-hub/deploy/backstage
|
||||||
|
docker compose up -d
|
||||||
|
# UI local: http://10.10.10.130:7007
|
||||||
|
```
|
||||||
|
|
||||||
|
Traefik: `backstage.ligbox.com.br` → `:7007`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxo diário Roger
|
||||||
|
|
||||||
|
```
|
||||||
|
Cursor + Companion → editar spec / ver fases
|
||||||
|
↓ git push
|
||||||
|
Forgejo CT130 → versionamento
|
||||||
|
↓ webhook / cron
|
||||||
|
Backstage TechDocs → equipa consulta no browser
|
||||||
|
```
|
||||||
159
specs/036-backstage-techdocs-speckit-companion/spec.md
Normal file
159
specs/036-backstage-techdocs-speckit-companion/spec.md
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
# Spec 036 — Backstage TechDocs + Spec Kit Companion (stack duplo)
|
||||||
|
|
||||||
|
**Criado:** 2026-06-22
|
||||||
|
**Solicitado por:** Roger
|
||||||
|
**Status:** 📋 **Backlog** — documentada 2026-06-22; implementação adiada (Roger)
|
||||||
|
**Prioridade:** **P3** (futuro — após Watchman 035 e estabilização Spec Hub)
|
||||||
|
**Hosts:** CT130 (Backstage web) · LAPTOP Roger (Spec Kit Companion)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumo
|
||||||
|
|
||||||
|
Stack **duplo** e complementar — não competem:
|
||||||
|
|
||||||
|
| Camada | Onde | Para quê |
|
||||||
|
|--------|------|----------|
|
||||||
|
| **Spec Kit Companion** | Cursor / VS Code (LAPTOP) | Trabalho diário: Specify → Plan → Tasks → Done, timeline, Mermaid |
|
||||||
|
| **Backstage + TechDocs** | CT130 (web LAN/WAN) | Catálogo por VM/serviço, docs públicas à equipa, search central |
|
||||||
|
| **Forgejo** | CT130 | Fonte de verdade Git (já existe) |
|
||||||
|
| **spec.ligbox.com.br** | CT130 nginx | Hub de entrada → links Backstage + Forgejo + Companion doc |
|
||||||
|
|
||||||
|
**Substitui:** portal `/specs/` HTML caseiro (fase out após Backstage MVP).
|
||||||
|
**Mantém:** Obsidian vault para notas; chat-bruto no repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph LAPTOP["LAPTOP Roger"]
|
||||||
|
Cursor[Cursor + Spec Kit Companion]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CT130["CT130"]
|
||||||
|
Forgejo[Forgejo Git]
|
||||||
|
BS[Backstage]
|
||||||
|
PG[(PostgreSQL)]
|
||||||
|
TD[TechDocs publisher]
|
||||||
|
Hub[spec.ligbox.com.br hub]
|
||||||
|
end
|
||||||
|
|
||||||
|
Cursor -->|push specs| Forgejo
|
||||||
|
Forgejo -->|clone / webhook| BS
|
||||||
|
BS --> PG
|
||||||
|
BS --> TD
|
||||||
|
TD -->|mkdocs build| Forgejo
|
||||||
|
Hub --> BS
|
||||||
|
Hub --> Forgejo
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backstage — modelagem Ligbox
|
||||||
|
|
||||||
|
### Entidades (Software Catalog)
|
||||||
|
|
||||||
|
| Kind | Exemplo | Anotação |
|
||||||
|
|------|---------|----------|
|
||||||
|
| **System** | `ligbox-platform` | Sistema global |
|
||||||
|
| **Component** | `vm122-ops-desk` | `backstage.io/techdocs-ref: dir:.` |
|
||||||
|
| **Component** | `vm112-onboard` | specs em `specs/001-*`, `017-*`, … |
|
||||||
|
| **API** | `desk-api` | OpenAPI futuro |
|
||||||
|
| **Resource** | `ct130-spec-hub` | Infra |
|
||||||
|
|
||||||
|
Ficheiro por VM: `catalog/vm122-catalog-info.yaml` (ou pasta `specs/` com annotation).
|
||||||
|
|
||||||
|
### TechDocs
|
||||||
|
|
||||||
|
- **Fonte:** `ligbox-ops-platform` — pastas `specs/NNN-*/` com `spec.md`, `mkdocs.yml` por spec ou monorepo plugin
|
||||||
|
- **Plugin:** [mkdocs-monorepo-plugin](https://github.com/backstage/mkdocs-monorepo-plugin) — nav por VM (Spotify pattern)
|
||||||
|
- **Build:** TechDocs backend no CT130 ou CI Forgejo → publicar em `techdocs-static/`
|
||||||
|
|
||||||
|
### URL alvo
|
||||||
|
|
||||||
|
| URL | Função |
|
||||||
|
|-----|--------|
|
||||||
|
| `backstage.ligbox.com.br` ou `spec.ligbox.com.br/catalog` | UI Backstage |
|
||||||
|
| Traefik CT114 | TLS + route |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec Kit Companion — LAPTOP
|
||||||
|
|
||||||
|
### Instalação (Cursor = VS Code)
|
||||||
|
|
||||||
|
1. Extensions → pesquisar **SpecKit Companion** (`alfredoperez.speckit-companion`)
|
||||||
|
2. Abrir pasta `ligbox-ops-platform` (clone Forgejo)
|
||||||
|
3. Sidebar Companion → lista specs com fase actual
|
||||||
|
4. Workflow: Specify → Plan → Tasks → Done (alinhado com `/speckit.*`)
|
||||||
|
|
||||||
|
### Repo
|
||||||
|
|
||||||
|
- [github.com/alfredoperez/speckit-companion](https://github.com/alfredoperez/speckit-companion)
|
||||||
|
- [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)
|
||||||
|
|
||||||
|
### Integração Ligbox
|
||||||
|
|
||||||
|
- `.specify/` e `specs/` já existem — Companion lê markdown local
|
||||||
|
- Push Forgejo após fase → Backstage reindexa TechDocs (web fica actualizado)
|
||||||
|
- Chat bruto: continua em `chat-bruto/`; link na spec.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CT130 — requisitos Backstage PoC
|
||||||
|
|
||||||
|
| Recurso | Actual | Mínimo Backstage PoC |
|
||||||
|
|---------|--------|----------------------|
|
||||||
|
| RAM | 4 GB (3.3 avail) | 4 GB apertado — **recomendado 6–8 GB** |
|
||||||
|
| Disco | 35 GB livre | ~5 GB (PG + node + cache) |
|
||||||
|
| Docker | forgejo + spec-portal | + backstage + postgres |
|
||||||
|
|
||||||
|
**Risco:** 4 GB pode funcionar em PoC leve; produção → aumentar RAM CT130 ou VM dedicada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fases
|
||||||
|
|
||||||
|
### Fase 0 — Companion (hoje, LAPTOP)
|
||||||
|
|
||||||
|
- [ ] Roger instala extensão no Cursor
|
||||||
|
- [ ] Abre `ligbox-ops-platform` local
|
||||||
|
- [ ] Testa uma spec (ex. 035-watchman) no viewer
|
||||||
|
|
||||||
|
### Fase 1 — Backstage PoC CT130
|
||||||
|
|
||||||
|
- [ ] `deploy/backstage/docker-compose.yml` (app + postgres)
|
||||||
|
- [ ] `catalog-info.yaml` root + 5 VMs (112, 122, 123, 104, 130)
|
||||||
|
- [ ] TechDocs: 3 specs piloto (031, 035, 027)
|
||||||
|
- [ ] Traefik route `backstage.ligbox.com.br`
|
||||||
|
|
||||||
|
### Fase 2 — Catálogo completo
|
||||||
|
|
||||||
|
- [ ] Todas as specs 001–035 no catalog
|
||||||
|
- [ ] Search Backstage indexa chat-bruto (plugin custom ou path)
|
||||||
|
- [ ] Hub `spec.ligbox.com.br` aponta para Backstage como browser principal
|
||||||
|
|
||||||
|
### Fase 3 — Deprecar HTML `/specs/`
|
||||||
|
|
||||||
|
- [ ] Redirect `/specs/` → Backstage TechDocs
|
||||||
|
- [ ] `refresh-spec-driver.sh` só para fallback ou remove
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critérios de aceitação (Fase 1)
|
||||||
|
|
||||||
|
1. Backstage abre no browser com 5 components (VMs) visíveis.
|
||||||
|
2. TechDocs renderiza spec 035 com sidebar e search.
|
||||||
|
3. Companion no Cursor mostra mesma spec com fases SDD.
|
||||||
|
4. Alteração em `spec.md` + push Forgejo → rebuild TechDocs em < 10 min.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentos relacionados
|
||||||
|
|
||||||
|
- Spec **031** — portal actual (a migrar)
|
||||||
|
- Spec **035** — Watchman
|
||||||
|
- [Backstage TechDocs](https://backstage.io/docs/features/techdocs/)
|
||||||
|
- [Spec Kit Companion](https://github.com/alfredoperez/speckit-companion)
|
||||||
20
specs/036-backstage-techdocs-speckit-companion/tasks.md
Normal file
20
specs/036-backstage-techdocs-speckit-companion/tasks.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Spec 036 — Tasks
|
||||||
|
|
||||||
|
**Status:** 📋 Backlog (adiada por Roger — 2026-06-22)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 0 — Companion LAPTOP (opcional, sem deploy)
|
||||||
|
|
||||||
|
- [ ] Instalar SpecKit Companion no Cursor
|
||||||
|
- [ ] Testar viewer com spec existente (ex. 035)
|
||||||
|
|
||||||
|
## Fase 1 — Backstage PoC CT130
|
||||||
|
|
||||||
|
- [ ] Adiar até P3 aprovado + RAM CT130 ≥ 6 GB
|
||||||
|
- [ ] `deploy/backstage/docker-compose.yml`
|
||||||
|
- [ ] `catalog-info.yaml` + TechDocs piloto
|
||||||
|
|
||||||
|
## Fase 2–3
|
||||||
|
|
||||||
|
- [ ] Ver `spec.md` — executar quando sair do backlog
|
||||||
|
|
@ -92,7 +92,7 @@ flowchart TD
|
||||||
|-------------|-------------|--------------|-------------------|
|
|-------------|-------------|--------------|-------------------|
|
||||||
| `cf_ligbox` | Zona numa conta Ligbox (037) | CF API token conta + `zone_id` | `https://dash.cloudflare.com/{account_id}/{zone_name}/dns` |
|
| `cf_ligbox` | Zona numa conta Ligbox (037) | CF API token conta + `zone_id` | `https://dash.cloudflare.com/{account_id}/{zone_name}/dns` |
|
||||||
| `cf_byo` | BYO wizard | Token vault sessão / entitlements | `https://dash.cloudflare.com/` (zona cliente) |
|
| `cf_byo` | BYO wizard | Token vault sessão / entitlements | `https://dash.cloudflare.com/` (zona cliente) |
|
||||||
| `openpanel_bind` | Subdomínio/site bundle OP | OpenAdmin `GET /api/domains/{domain}/dns` ou `rndc`/zone export | `https://openpanel.ligbox.com.br/domains/{domain}/dns` |
|
| `openpanel_bind` | Subdomínio/site bundle OP | OpenAdmin `GET /api/domains/{domain}/dns` ou `rndc`/zone export | Staff: `https://admin.openpanel.ligbox.com.br/domains/dns?domain={domain}` · Cliente: `openpanel.ligbox.com.br/domains/{domain}/dns` |
|
||||||
| `public_resolver` | Sempre (fallback / externo) | `dig` + `dns/verify` VM112 | — |
|
| `public_resolver` | Sempre (fallback / externo) | `dig` + `dns/verify` VM112 | — |
|
||||||
| `planned_ligbox` | Ligbox antes de apply | `mail_dns_records(domain)` wizard | — (preview only) |
|
| `planned_ligbox` | Ligbox antes de apply | `mail_dns_records(domain)` wizard | — (preview only) |
|
||||||
|
|
||||||
|
|
@ -249,17 +249,29 @@ Gerente vê read-only + link externo **se** tiver permissão (BYO: link CF clien
|
||||||
|
|
||||||
## 6. Links «Editar aqui» (deep-link)
|
## 6. Links «Editar aqui» (deep-link)
|
||||||
|
|
||||||
| Provider | Quem vê o botão | URL | Mecanismo |
|
### Regra Roger (2026-06-25) — staff vs cliente
|
||||||
|----------|-----------------|-----|-----------|
|
|
||||||
| Cloudflare Ligbox | staff `super_admin`, `ops_lead`, `devops`, `seo` | dash.cloudflare.com | Conta + zone_id de `dns/resolve` |
|
|
||||||
| Cloudflare BYO | gerente (zona dele) + staff | dash.cloudflare.com | Zona BYO |
|
|
||||||
| OpenPanel | staff + gerente hub | openpanel.ligbox.com.br | Autologin bridge 027 |
|
|
||||||
| Registrador externo | gerente | URL detectada ou genérica | `instructions.registrar_url` |
|
|
||||||
| Registro.br | gerente BR | https://registro.br | Manual |
|
|
||||||
|
|
||||||
**Desk:** botão abre **nova tab** — nunca iframe CF (CSP).
|
| Princípio | Detalhe |
|
||||||
|
|-----------|---------|
|
||||||
|
| **Visualização DNS** | Read-only na Console/Desk — **perfeita para suporte** (apontamentos, faltas, erros, origem) |
|
||||||
|
| **Login Console `/admin/*`** | **Uma vez** no shell Admin (`AdminShell`) — **não** por página. Handoff Desk→Console (`POST /auth/console-handoff` + `?desk_handoff=`) evita re-login ao navegar setups do Wizard |
|
||||||
|
| **Desk → Console** | Botão **«Abrir no Console ↗»** no painel DNS (staff com `can_read_dns_viewer`) |
|
||||||
|
| **Suporte NUNCA** | Painel **cliente** OpenPanel (`openpanel.ligbox.com.br/domains/…/dns`) — dá 500 e é vista errada |
|
||||||
|
| **Suporte SEMPRE** | **OpenAdmin** (`admin.openpanel.ligbox.com.br/domains/dns?domain=…`) ou Cloudflare **conta partner Ligbox** |
|
||||||
|
| **Gerente domínio** (futuro) | Links `audience: client` — painel cliente / CF BYO própria; **sem** OpenAdmin |
|
||||||
|
|
||||||
**Implementação Fase 1:** só link CF Ligbox (conta conhecida). Fase 2: OpenPanel autologin.
|
Campo `audience` em cada link: `staff` | `client` | `all`.
|
||||||
|
|
||||||
|
| Provider | Quem vê | URL staff | URL cliente |
|
||||||
|
|----------|---------|-----------|-------------|
|
||||||
|
| Cloudflare Ligbox | staff | `dash.cloudflare.com/.../zones/{zone_id}/dns` | — (gerente: «contacte suporte») |
|
||||||
|
| Cloudflare BYO | gerente + staff | dash CF zona BYO | idem |
|
||||||
|
| OpenPanel BIND | staff | `https://admin.openpanel.ligbox.com.br/domains/dns?domain={domain}` | `https://openpanel.ligbox.com.br/domains/{domain}/dns` |
|
||||||
|
| Registrador | todos | registro.br / detectado | idem |
|
||||||
|
|
||||||
|
**Desk / Console:** botão abre **nova tab** — nunca iframe.
|
||||||
|
|
||||||
|
**Implementação:** `dns_viewer._edit_links()` + `permissions.can_open_openpanel_admin_link()` vs `can_open_openpanel_client_link()`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -269,7 +281,8 @@ Gerente vê read-only + link externo **se** tiver permissão (BYO: link CF clien
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| `dns.viewer.read` — ver painel | super_admin, ops_lead, technician, noc, seo, devops, developer |
|
| `dns.viewer.read` — ver painel | super_admin, ops_lead, technician, noc, seo, devops, developer |
|
||||||
| Ver link Cloudflare Ligbox | super_admin, ops_lead, devops, seo |
|
| Ver link Cloudflare Ligbox | super_admin, ops_lead, devops, seo |
|
||||||
| Ver link OpenPanel | super_admin, ops_lead, sales_admin, sales_support, seo |
|
| Ver link OpenAdmin (staff) | super_admin, ops_lead, devops, sales_admin, sales_support, seo, technician, noc |
|
||||||
|
| Ver link OpenPanel cliente | domain_manager (futuro) — **proibido** para staff suporte |
|
||||||
| Gerente domínio `/admin` | Só domínio próprio; sem CF Ligbox interna |
|
| Gerente domínio `/admin` | Só domínio próprio; sem CF Ligbox interna |
|
||||||
| Agente A2/A3 (Desk) | Lê viewer API — sugere fixes, não edita |
|
| Agente A2/A3 (Desk) | Lê viewer API — sugere fixes, não edita |
|
||||||
|
|
||||||
|
|
@ -396,6 +409,69 @@ git tag -a dns-viewer-pre-v1-YYYYMMDD -m "Antes DNS Viewer V1"
|
||||||
|
|
||||||
Entrega monorepo V1–V3 + patches V4: commit **`038fb8f`** (2026-06-25).
|
Entrega monorepo V1–V3 + patches V4: commit **`038fb8f`** (2026-06-25).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Incidente 2026-06-25 — Console «Failed to fetch» no login DNS
|
||||||
|
|
||||||
|
### Sintoma
|
||||||
|
|
||||||
|
`console.ligbox.com.br/admin/dominio` → Login Desk → **Failed to fetch** (abaixo do botão Entrar).
|
||||||
|
«Sem dados DNS.» aparece em baixo — **não é erro**, é estado vazio antes de login + consulta.
|
||||||
|
|
||||||
|
### Causa
|
||||||
|
|
||||||
|
O frontend Console (`api.js`) enviava `credentials: 'include'` nas chamadas cross-origin para `https://api.ops.ligbox.com.br`.
|
||||||
|
A API Desk responde `Access-Control-Allow-Origin: *`. Browsers **bloqueiam** pedidos credenciados com wildcard → `TypeError: Failed to fetch`.
|
||||||
|
|
||||||
|
### Correção
|
||||||
|
|
||||||
|
1. Remover `credentials: 'include'` do `apiGet` / `apiPost` — auth usa **Bearer JWT** em `localStorage`, não cookies.
|
||||||
|
2. API Desk: `CORS_ORIGINS` explícito inclui `https://console.ligbox.com.br` (deixa de depender só de `*`).
|
||||||
|
3. Console nginx: `index.html` com `Cache-Control: no-cache` para forçar bundle novo após deploy.
|
||||||
|
4. Mensagens de erro amigáveis quando a rede/CORS falha; sessão expirada (401) pede re-login.
|
||||||
|
|
||||||
|
| Ficheiro | Alteração |
|
||||||
|
|----------|-----------|
|
||||||
|
| `specs/019-.../deploy/frontend/src/lib/api.js` | Sem `credentials`; `wrapNetworkError` |
|
||||||
|
| `specs/019-.../deploy/frontend/src/views/admin/AdminDominio.jsx` | 401 → clear session |
|
||||||
|
| `projects/ops-desk/api/app/main.py` | `CORS_ORIGINS` |
|
||||||
|
| `specs/019-.../deploy/nginx/default.conf` | no-cache em `index.html` |
|
||||||
|
|
||||||
|
### Consulta DNS (ex.: diarissima.com)
|
||||||
|
|
||||||
|
Após fix, `GET /api/v1/domain-console/dns/viewer/diarissima.com` retorna **200** (~3s) com registos OpenPanel BIND — validado 2026-06-25.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Incidente 2026-06-25 — Link «Editar no OpenPanel» abria painel cliente (500)
|
||||||
|
|
||||||
|
### Sintoma
|
||||||
|
|
||||||
|
Staff em Console → `diarissima.com` → **Editar no OpenPanel** → `openpanel.ligbox.com.br/domains/diarissima.com/dns` → **ERROR 500** (vista **cliente**, não OpenAdmin).
|
||||||
|
|
||||||
|
### Causa
|
||||||
|
|
||||||
|
`edit_links` apontava `OPENPANEL_URL` (painel utilizador) em vez de `OPENADMIN_URL` (DNS Zone Editor staff).
|
||||||
|
|
||||||
|
### Correção
|
||||||
|
|
||||||
|
| Antes | Depois (staff) |
|
||||||
|
|-------|----------------|
|
||||||
|
| `openpanel.ligbox.com.br/domains/{d}/dns` | `admin.openpanel.ligbox.com.br/domains/dns?domain={d}` |
|
||||||
|
| Label «Editar no OpenPanel» | «Editar DNS (OpenAdmin)» |
|
||||||
|
| `provider: openpanel_bind` | `provider: openpanel_admin`, `audience: staff` |
|
||||||
|
|
||||||
|
Gerente futuro (`domain_manager`) receberá link cliente separado — staff **nunca**.
|
||||||
|
|
||||||
|
### Ficheiros
|
||||||
|
|
||||||
|
`openpanel_dns.py`, `dns_viewer.py`, `permissions.py`, `domain_console_routes.py`, `dns-viewer.md` §6.
|
||||||
|
|
||||||
|
### Credenciais
|
||||||
|
|
||||||
|
- **Login Desk** exige utilizador **Ops Desk** (ex.: `Roger`, `root`) — **não** `ligboxadmin` (conta OpenAdmin/hosting).
|
||||||
|
- Após login: introduzir domínio e **Consultar DNS**.
|
||||||
|
|
||||||
### Endpoint legado (mantido até cutover)
|
### Endpoint legado (mantido até cutover)
|
||||||
|
|
||||||
`GET /api/v1/dns/cloudflare/records` — **não remover** enquanto rollback não estiver validado em produção ≥7 dias.
|
`GET /api/v1/dns/cloudflare/records` — **não remover** enquanto rollback não estiver validado em produção ≥7 dias.
|
||||||
|
|
|
||||||
172
specs/SESSION-CHECKPOINT-2026-06-21-ROGER-CONSOLE-UX.md
Normal file
172
specs/SESSION-CHECKPOINT-2026-06-21-ROGER-CONSOLE-UX.md
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
# Checkpoint de sessão — Roger — 2026-06-21
|
||||||
|
|
||||||
|
**Guardado para retomar.** Consolida decisões, specs e ficheiros desta conversa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visão produto (3 portais — mesma shell)
|
||||||
|
|
||||||
|
| Portal | Quem | URL | Agrega |
|
||||||
|
|--------|------|-----|--------|
|
||||||
|
| **Admin Ligbox** | Roger + staff | `console.ligbox.com.br/comercial` + `/ops` | Preços, clientes, gateway, provision, ops |
|
||||||
|
| **Gerente domínio** | `admin@{dom}` | `console.ligbox.com.br/admin` | Contas, quotas, Files, DMARC, plano, boleto/PIX |
|
||||||
|
| **Utilizador** | `{user}@{dom}` | `console.ligbox.com.br/me` (Fase B) | OOO, assinatura — **ou só webmail** (Fase 1) |
|
||||||
|
|
||||||
|
**Regra de ouro:** FOSS, OpenPanel, Carbonio, Nextcloud **nunca** na UI. Só Ligbox Console.
|
||||||
|
|
||||||
|
**Motores invisíveis:** FOSS (venda/billing) · OpenPanel (provision backend) · Wizard VM112 (motor `/admin`) · Desk VM122 (orquestração) · VM116 Files · Gateway boleto/PIX
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decisões UX fixadas (Roger 2026-06-21)
|
||||||
|
|
||||||
|
| # | Decisão | Valor |
|
||||||
|
|---|---------|-------|
|
||||||
|
| U1 | Shell partilhada | Design system React único |
|
||||||
|
| U2 | URL marca | **`console.ligbox.com.br`** — login detecta role |
|
||||||
|
| U3 | Ops Wazuh | **Mesmo shell** — `/ops` (Spec 019 + 030) |
|
||||||
|
| U4 | Tom visual | Caloroso BR — tokens Desk (`styles.css` creme + bordô) |
|
||||||
|
| U5 | Prioridade UI | **Gerente `/admin` primeiro** |
|
||||||
|
| U6 | Referência layout | **Spec 030 Mission Board** (NÃO 029-tickets) |
|
||||||
|
| U7 | Nextcloud Mail | **Opcional por domínio** — toggle `/admin`, default OFF |
|
||||||
|
| U8 | Aprovação preços | **Admin Ligbox** — fila Roger, não backoffice FOSS |
|
||||||
|
| U9 | Utilizador `/me` | **Opcional Fase B** — webmail Carbonio basta Fase 1 |
|
||||||
|
|
||||||
|
### Redirects legacy (Traefik)
|
||||||
|
|
||||||
|
- `onboard.ligbox.com.br/admin` → 301 `console.ligbox.com.br/admin`
|
||||||
|
- `desk.ligbox.com.br` → 301 `console.ligbox.com.br/ops`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mapa das specs (não confundir)
|
||||||
|
|
||||||
|
| Spec | Nome | Papel |
|
||||||
|
|------|------|-------|
|
||||||
|
| **029-agentic-ops-runbooks** | Backend agentes | API A0–A7, Ollama, cenários |
|
||||||
|
| **030-agentic-ops-ui** | **Mission Board** ✅ | UX referência — status bar, 3 colunas, cards, contexto |
|
||||||
|
| 029-tickets-workspace | Motor tickets Desk | KPIs tickets — **não** referência Console |
|
||||||
|
| **034** | Nextcloud + Carbonio | Mail hot + Files warm, NC-1→NC-5, VM116 |
|
||||||
|
| **034-UI** | Visão processo Console | Componentes ProvisionPipeline, QuotaDualBar… |
|
||||||
|
| **035** | Bundles FOSS/OpenPanel | Comercial, entitlements, Domain Admin |
|
||||||
|
| **035-UX** | Ligbox Console shell | `ligbox-console-shell.md` |
|
||||||
|
| **035-C** | Admin Ligbox staff | Preços, catálogo, clientes |
|
||||||
|
| **019** | Ops Console CH-* | Integrar em `/ops` mesmo shell |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Bundles comerciais (preços — Roger aprovar via Admin Ligbox)
|
||||||
|
|
||||||
|
| Plano | Contas | Mail/conta | Files/conta | Preço sugerido |
|
||||||
|
|-------|--------|------------|-------------|----------------|
|
||||||
|
| Starter | 10 | 20 GB | 100 GB | R$ 249/mês |
|
||||||
|
| Business | 25 | 30 GB | 200 GB | R$ 549/mês |
|
||||||
|
| Enterprise | 50 | 50 GB | 300 GB | R$ 999/mês |
|
||||||
|
| Custom | 10–50 | 20–50 GB | 100–500 GB | configurável |
|
||||||
|
|
||||||
|
Gateway: **boleto + PIX QR** (ASAAS vs Iugu — pendente).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Spec 030 → Console (referência layout)
|
||||||
|
|
||||||
|
**Código:** `projects/ops-desk/frontend/assets/agentic-ops.js` + `agentic-ops.css`
|
||||||
|
|
||||||
|
| Padrão 030 | Uso Console |
|
||||||
|
|------------|-------------|
|
||||||
|
| Status bar fixa | Plano, contas, pagamento, health |
|
||||||
|
| 3 colunas: nav \| board \| contexto | `/admin` e `/ops` |
|
||||||
|
| Cards + severidade | Contas / incidentes provision |
|
||||||
|
| Context panel | Detalhe conta / thread ops |
|
||||||
|
| 1 problema = 1 card | Deduplicação |
|
||||||
|
| Poll 30s | Quotas, pagamento, ops |
|
||||||
|
|
||||||
|
**Nota avaliação 034:** Spec 030 = chassis **8/10** ops; processos gerente precisam componentes 034 (**ProvisionPipeline**, **QuotaDualBar**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Componentes novos Spec 034-UI
|
||||||
|
|
||||||
|
| Componente | Função |
|
||||||
|
|------------|--------|
|
||||||
|
| `ProvisionPipeline` | NC-1→NC-5 humanizado («Email → Files → Mail NC → Pronto») |
|
||||||
|
| `QuotaDualBar` | Barra mail GB + Files GB por conta |
|
||||||
|
| `StorageTierBadge` | Caixa (hot) / Files (warm) |
|
||||||
|
| `DomainFilesToggle` | Mail Nextcloud ON/OFF domínio |
|
||||||
|
| `StackHealthTile` | VM112 + VM116 staff |
|
||||||
|
| `AccountCard` | Card Spec 030 + badges 034 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Ficheiros criados/actualizados nesta sessão
|
||||||
|
|
||||||
|
### Novos
|
||||||
|
|
||||||
|
```
|
||||||
|
specs/035-ligbox-mail-bundles-foss-openpanel/
|
||||||
|
ligbox-console-shell.md # Shell unificada console.ligbox.com.br
|
||||||
|
ligbox-system-admin-ui.md # Admin Ligbox staff (comercial)
|
||||||
|
user-self-service-ui.md # Fase B placeholder (actualizado)
|
||||||
|
foss-products.md
|
||||||
|
domain-manager-console-ui.md
|
||||||
|
tasks.md, spec.md, mockups/…
|
||||||
|
|
||||||
|
specs/034-nextcloud-carbonio-vm112-integration/
|
||||||
|
034-console-process-ui.md # Visão processo Console 034
|
||||||
|
|
||||||
|
specs/SESSION-CHECKPOINT-2026-06-21-ROGER-CONSOLE-UX.md # ESTE FICHEIRO
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actualizados
|
||||||
|
|
||||||
|
- `034/spec.md` — toggle Mail NC, links 034-UI
|
||||||
|
- `034/tasks.md` — fase 2-UI
|
||||||
|
- `035/spec.md` — §2.2.1 Mail opcional, §14 decisões, entitlements
|
||||||
|
- `035/ligbox-console-shell.md` — Spec 030, tokens Desk
|
||||||
|
- `035/domain-manager-console-ui.md` — 3 portais, pagamento, toggle NC
|
||||||
|
- `035/tasks.md` — UX-A, Fase C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. URLs produção (referência)
|
||||||
|
|
||||||
|
| URL | Estado |
|
||||||
|
|-----|--------|
|
||||||
|
| `https://onboard.ligbox.com.br/admin` | Live — Domain Admin SPA |
|
||||||
|
| `https://desk.ligbox.com.br` | Staff Desk |
|
||||||
|
| `https://financeiro.ligbox.com.br` | FOSSBilling |
|
||||||
|
| `https://console.ligbox.com.br` | **Alvo** — ainda implementar |
|
||||||
|
| VM116 Nextcloud | **Não provisionada** (034 Fase 0) |
|
||||||
|
|
||||||
|
**Demo domínio:** `cenario-demo.ops.ligbox.com.br` · admin `admin@cenario-demo.ops.ligbox.com.br` / `Demo805353`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Ordem implementação (quando voltar)
|
||||||
|
|
||||||
|
1. **UX-0** — AppLayout React + tokens Desk + login role
|
||||||
|
2. **UX-A / UI-034-A** — `/admin/contas` + ProvisionPipeline + QuotaDualBar (mock Files OK)
|
||||||
|
3. **UX-A3 / UI-034-B** — `/admin/files` + DomainFilesToggle
|
||||||
|
4. **Fase 0 035** — produtos FOSS + fila preços Admin Ligbox
|
||||||
|
5. **Fase 0 034** — VM116 Nextcloud
|
||||||
|
6. **UX-B/C** — `/comercial` + `/ops` (Spec 030 migrate)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Pendências Roger
|
||||||
|
|
||||||
|
- [ ] Aprovar preços Starter/Business/Enterprise (Admin Ligbox — quando existir)
|
||||||
|
- [ ] Gateway ASAAS vs Iugu
|
||||||
|
- [ ] Aprovar VM116 recursos (034 Fase 0)
|
||||||
|
- [ ] Senha única Carbonio+NC Fase 1? (sim vs convite)
|
||||||
|
- [ ] EasyDMARC pool MSP confirmar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Transcript conversa
|
||||||
|
|
||||||
|
`/root/.cursor/projects/tmp-f3effc7d-070a-434a-86c5-6bd8ba7a2b09/agent-transcripts/2c7effcf-a5e9-47b1-a883-70336459a56f/2c7effcf-a5e9-47b1-a883-70336459a56f.jsonl`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Checkpoint gerado automaticamente — sessão Roger Console UX · 2026-06-21*
|
||||||
Loading…
Reference in a new issue