From c1881f58e627caaf6d21d278ac43ba1c0c13669c Mon Sep 17 00:00:00 2001 From: Ligbox Spec Hub Date: Thu, 25 Jun 2026 20:10:17 +0000 Subject: [PATCH] chore: sync Console SSO, DNS viewer, specs e infra docs pendentes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .cursor/rules/spec-driver-sync.mdc | 88 +++++ _sidebar.md | 72 +++- contracts/README.md | 104 ++++++ contracts/manifest.yaml | 60 ++++ contracts/stack-services.yaml | 274 ++++++++++++++++ deploy/manifest.yaml | 13 +- deploy/vm116-nextcloud/.env.example | 27 ++ deploy/vm116-nextcloud/README.md | 55 ++++ deploy/vm116-nextcloud/docker-compose.yml | 71 ++++ .../proxmox/vm124-user-data.yaml | 32 ++ deploy/vm116-nextcloud/scripts/backup-occ.sh | 21 ++ deploy/vm116-nextcloud/scripts/bootstrap.sh | 68 ++++ deploy/vm116-nextcloud/scripts/install.sh | 35 ++ .../scripts/occ-provision-user.sh | 34 ++ .../scripts/proxmox-create-vm.sh | 83 +++++ .../traefik/nextcloud-hub.yml.snippet | 33 ++ docs/network/INTERFACES_PROXMOX.md | 16 + .../ORIENTACAO_DNS53_PROXMOX_20260622.md | 127 ++++++++ docs/vms/VM116.md | 35 +- docs/vms/VM124.md | 37 +++ docs/vms/manifest.yaml | 60 ++++ projects/cloudflare-agents/README.md | 7 + projects/cloudflare-agents/deploy.sh | 31 ++ projects/cloudflare-agents/onboard/README.md | 48 +++ .../cloudflare-agents/onboard/package.json | 18 + .../onboard/secrets/.gitignore | 6 + .../onboard/secrets/README.txt | 12 + .../cloudflare-agents/onboard/src/index.ts | 60 ++++ .../onboard/src/onboard-maestro.ts | 131 ++++++++ .../onboard/src/vm112-client.ts | 37 +++ .../cloudflare-agents/onboard/tsconfig.json | 13 + .../cloudflare-agents/onboard/wrangler.toml | 30 ++ .../traefik-routes-snippet.yml | 8 +- projects/ops-desk/api/app/auth_routes.py | 41 ++- projects/ops-desk/api/app/console_handoff.py | 35 ++ projects/ops-desk/api/app/dns_viewer.py | 62 +++- .../ops-desk/api/app/domain_console_routes.py | 5 +- projects/ops-desk/api/app/openpanel_dns.py | 28 +- projects/ops-desk/api/app/permissions.py | 22 +- .../ops-desk/frontend/assets/dns-viewer.js | 22 +- .../wizard/backend/app/routers/project.py | 88 +++++ .../app/services/cf_client_accounts.py | 102 ++++++ .../app/services/dns_account_registry.py | 190 +++++++++++ .../backend/app/services/dns_byo_vault.py | 102 ++++++ .../backend/app/services/project_identity.py | 163 +++++++++ .../cloudflare-zone-provision.md | 308 ++++++++++++++++++ specs/004-onboard-funnel-events/spec.md | 10 +- specs/004-onboard-funnel-events/tasks.md | 2 +- .../src/components/AdminLoginGate.jsx | 62 ++++ .../frontend/src/layouts/AdminShell.jsx | 72 +++- .../deploy/frontend/src/lib/api.js | 33 +- .../deploy/frontend/src/lib/auth.js | 101 +++++- .../frontend/src/views/admin/AdminDominio.jsx | 81 ++--- .../deploy/nginx/default.conf | 4 + .../DNS53_OPENPANEL_PORTA53.md | 226 +++++++++++++ .../spec.md | 18 + .../034-console-process-ui.md | 304 +++++++++++++++++ .../contracts/nextcloud-provisioning-api.md | 4 +- .../infrastructure.md | 59 ++-- .../quickstart.md | 9 +- .../spec.md | 49 +-- .../tasks.md | 27 +- .../contracts/stack-services.yaml | 274 ++++++++++++++++ .../contracts/status-snapshot.md | 57 ++++ specs/035-ligbox-watchman/quickstart.md | 81 +++++ specs/035-ligbox-watchman/spec.md | 275 ++++++++++++++++ specs/035-ligbox-watchman/tasks.md | 46 +++ .../quickstart.md | 54 +++ .../spec.md | 159 +++++++++ .../tasks.md | 20 ++ .../dns-viewer.md | 98 +++++- ...-CHECKPOINT-2026-06-21-ROGER-CONSOLE-UX.md | 172 ++++++++++ 72 files changed, 5028 insertions(+), 183 deletions(-) create mode 100644 .cursor/rules/spec-driver-sync.mdc create mode 100644 contracts/README.md create mode 100644 contracts/manifest.yaml create mode 100644 contracts/stack-services.yaml create mode 100644 deploy/vm116-nextcloud/.env.example create mode 100644 deploy/vm116-nextcloud/README.md create mode 100644 deploy/vm116-nextcloud/docker-compose.yml create mode 100644 deploy/vm116-nextcloud/proxmox/vm124-user-data.yaml create mode 100755 deploy/vm116-nextcloud/scripts/backup-occ.sh create mode 100755 deploy/vm116-nextcloud/scripts/bootstrap.sh create mode 100755 deploy/vm116-nextcloud/scripts/install.sh create mode 100755 deploy/vm116-nextcloud/scripts/occ-provision-user.sh create mode 100755 deploy/vm116-nextcloud/scripts/proxmox-create-vm.sh create mode 100644 deploy/vm116-nextcloud/traefik/nextcloud-hub.yml.snippet create mode 100644 docs/network/ORIENTACAO_DNS53_PROXMOX_20260622.md create mode 100644 docs/vms/VM124.md create mode 100644 docs/vms/manifest.yaml create mode 100644 projects/cloudflare-agents/README.md create mode 100644 projects/cloudflare-agents/deploy.sh create mode 100644 projects/cloudflare-agents/onboard/README.md create mode 100644 projects/cloudflare-agents/onboard/package.json create mode 100644 projects/cloudflare-agents/onboard/secrets/.gitignore create mode 100644 projects/cloudflare-agents/onboard/secrets/README.txt create mode 100644 projects/cloudflare-agents/onboard/src/index.ts create mode 100644 projects/cloudflare-agents/onboard/src/onboard-maestro.ts create mode 100644 projects/cloudflare-agents/onboard/src/vm112-client.ts create mode 100644 projects/cloudflare-agents/onboard/tsconfig.json create mode 100644 projects/cloudflare-agents/onboard/wrangler.toml create mode 100644 projects/ops-desk/api/app/console_handoff.py create mode 100644 projects/wizard/backend/app/routers/project.py create mode 100644 projects/wizard/backend/app/services/cf_client_accounts.py create mode 100644 projects/wizard/backend/app/services/dns_account_registry.py create mode 100644 projects/wizard/backend/app/services/dns_byo_vault.py create mode 100644 projects/wizard/backend/app/services/project_identity.py create mode 100644 specs/004-onboard-funnel-events/cloudflare-zone-provision.md create mode 100644 specs/019-ops-console-active-operations/deploy/frontend/src/components/AdminLoginGate.jsx create mode 100644 specs/028-openpanel-ce-ligbox-reengineering/DNS53_OPENPANEL_PORTA53.md create mode 100644 specs/034-nextcloud-carbonio-vm112-integration/034-console-process-ui.md create mode 100644 specs/035-ligbox-watchman/contracts/stack-services.yaml create mode 100644 specs/035-ligbox-watchman/contracts/status-snapshot.md create mode 100644 specs/035-ligbox-watchman/quickstart.md create mode 100644 specs/035-ligbox-watchman/spec.md create mode 100644 specs/035-ligbox-watchman/tasks.md create mode 100644 specs/036-backstage-techdocs-speckit-companion/quickstart.md create mode 100644 specs/036-backstage-techdocs-speckit-companion/spec.md create mode 100644 specs/036-backstage-techdocs-speckit-companion/tasks.md create mode 100644 specs/SESSION-CHECKPOINT-2026-06-21-ROGER-CONSOLE-UX.md diff --git a/.cursor/rules/spec-driver-sync.mdc b/.cursor/rules/spec-driver-sync.mdc new file mode 100644 index 0000000..8cc1f3d --- /dev/null +++ b/.cursor/rules/spec-driver-sync.mdc @@ -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__SPEC_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: " +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 diff --git a/_sidebar.md b/_sidebar.md index 0699565..42e6282 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -51,6 +51,7 @@ - [plan.md](specs/004-onboard-funnel-events/plan.md) - [tasks.md](specs/004-onboard-funnel-events/tasks.md) - [research.md](specs/004-onboard-funnel-events/research.md) + - [cloudflare-zone-provision.md](specs/004-onboard-funnel-events/cloudflare-zone-provision.md) - **contracts/** - [webhook-funnel-events](specs/004-onboard-funnel-events/contracts/webhook-funnel-events.md) - **checklists/** @@ -77,14 +78,9 @@ - [📄 spec.md](specs/027-desk-rbac-function-matrix/spec.md) - [quickstart.md](specs/027-desk-rbac-function-matrix/quickstart.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/** - [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 - **019-ops-console-active-operations** - [📄 spec.md](specs/019-ops-console-active-operations/spec.md) @@ -144,3 +140,67 @@ - **021-wizard-cybersecurity-telemetry** - [📄 spec.md](specs/021-wizard-cybersecurity-telemetry/spec.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) diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 0000000..b881b69 --- /dev/null +++ b/contracts/README.md @@ -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. diff --git a/contracts/manifest.yaml b/contracts/manifest.yaml new file mode 100644 index 0000000..a643ffa --- /dev/null +++ b/contracts/manifest.yaml @@ -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 diff --git a/contracts/stack-services.yaml b/contracts/stack-services.yaml new file mode 100644 index 0000000..5e1dc18 --- /dev/null +++ b/contracts/stack-services.yaml @@ -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: [...] diff --git a/deploy/manifest.yaml b/deploy/manifest.yaml index 16e5da9..a643ffa 100644 --- a/deploy/manifest.yaml +++ b/deploy/manifest.yaml @@ -13,10 +13,13 @@ vm112: vm122: tag: main - description: Ops Desk API + worker + frontend + 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/ @@ -41,6 +44,14 @@ vm104: - 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 diff --git a/deploy/vm116-nextcloud/.env.example b/deploy/vm116-nextcloud/.env.example new file mode 100644 index 0000000..be9475b --- /dev/null +++ b/deploy/vm116-nextcloud/.env.example @@ -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 diff --git a/deploy/vm116-nextcloud/README.md b/deploy/vm116-nextcloud/README.md new file mode 100644 index 0000000..20f3950 --- /dev/null +++ b/deploy/vm116-nextcloud/README.md @@ -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) diff --git a/deploy/vm116-nextcloud/docker-compose.yml b/deploy/vm116-nextcloud/docker-compose.yml new file mode 100644 index 0000000..de66ecd --- /dev/null +++ b/deploy/vm116-nextcloud/docker-compose.yml @@ -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 diff --git a/deploy/vm116-nextcloud/proxmox/vm124-user-data.yaml b/deploy/vm116-nextcloud/proxmox/vm124-user-data.yaml new file mode 100644 index 0000000..ed88780 --- /dev/null +++ b/deploy/vm116-nextcloud/proxmox/vm124-user-data.yaml @@ -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 diff --git a/deploy/vm116-nextcloud/scripts/backup-occ.sh b/deploy/vm116-nextcloud/scripts/backup-occ.sh new file mode 100755 index 0000000..12d76de --- /dev/null +++ b/deploy/vm116-nextcloud/scripts/backup-occ.sh @@ -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" diff --git a/deploy/vm116-nextcloud/scripts/bootstrap.sh b/deploy/vm116-nextcloud/scripts/bootstrap.sh new file mode 100755 index 0000000..2523d5f --- /dev/null +++ b/deploy/vm116-nextcloud/scripts/bootstrap.sh @@ -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" diff --git a/deploy/vm116-nextcloud/scripts/install.sh b/deploy/vm116-nextcloud/scripts/install.sh new file mode 100755 index 0000000..ac5f4b6 --- /dev/null +++ b/deploy/vm116-nextcloud/scripts/install.sh @@ -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" diff --git a/deploy/vm116-nextcloud/scripts/occ-provision-user.sh b/deploy/vm116-nextcloud/scripts/occ-provision-user.sh new file mode 100755 index 0000000..f2e094c --- /dev/null +++ b/deploy/vm116-nextcloud/scripts/occ-provision-user.sh @@ -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" diff --git a/deploy/vm116-nextcloud/scripts/proxmox-create-vm.sh b/deploy/vm116-nextcloud/scripts/proxmox-create-vm.sh new file mode 100755 index 0000000..0a6a924 --- /dev/null +++ b/deploy/vm116-nextcloud/scripts/proxmox-create-vm.sh @@ -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 < 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..workers.dev/health" +echo "Depois configure rota agents.ligbox.com.br no dashboard ou wrangler.toml" diff --git a/projects/cloudflare-agents/onboard/README.md b/projects/cloudflare-agents/onboard/README.md new file mode 100644 index 0000000..de64d3e --- /dev/null +++ b/projects/cloudflare-agents/onboard/README.md @@ -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/...` diff --git a/projects/cloudflare-agents/onboard/package.json b/projects/cloudflare-agents/onboard/package.json new file mode 100644 index 0000000..87ebfb2 --- /dev/null +++ b/projects/cloudflare-agents/onboard/package.json @@ -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" + } +} diff --git a/projects/cloudflare-agents/onboard/secrets/.gitignore b/projects/cloudflare-agents/onboard/secrets/.gitignore new file mode 100644 index 0000000..385fbd1 --- /dev/null +++ b/projects/cloudflare-agents/onboard/secrets/.gitignore @@ -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 diff --git a/projects/cloudflare-agents/onboard/secrets/README.txt b/projects/cloudflare-agents/onboard/secrets/README.txt new file mode 100644 index 0000000..3572189 --- /dev/null +++ b/projects/cloudflare-agents/onboard/secrets/README.txt @@ -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 diff --git a/projects/cloudflare-agents/onboard/src/index.ts b/projects/cloudflare-agents/onboard/src/index.ts new file mode 100644 index 0000000..c264e0e --- /dev/null +++ b/projects/cloudflare-agents/onboard/src/index.ts @@ -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 { + 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, + })); + }, +}; diff --git a/projects/cloudflare-agents/onboard/src/onboard-maestro.ts b/projects/cloudflare-agents/onboard/src/onboard-maestro.ts new file mode 100644 index 0000000..4b0365d --- /dev/null +++ b/projects/cloudflare-agents/onboard/src/onboard-maestro.ts @@ -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 { + 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("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 { + return Response.json({ agent: "proj-mail", status: "delegate-to-maestro" }); + } +} + +export class CfZoneAgent extends DurableObject { + async fetch(request: Request): Promise { + return Response.json({ agent: "cf-zone", status: "delegate-to-maestro" }); + } +} + +export class CfHandoffAgent extends DurableObject { + async fetch(request: Request): Promise { + return Response.json({ agent: "cf-handoff", status: "delegate-to-maestro" }); + } +} diff --git a/projects/cloudflare-agents/onboard/src/vm112-client.ts b/projects/cloudflare-agents/onboard/src/vm112-client.ts new file mode 100644 index 0000000..1f51809 --- /dev/null +++ b/projects/cloudflare-agents/onboard/src/vm112-client.ts @@ -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, +): Promise> { + 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; + 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> { + const res = await fetch(`${cfg.baseUrl}${path}`, { + headers: { "X-Onboarding-Session": cfg.sessionId }, + }); + const data = (await res.json().catch(() => ({}))) as Record; + if (!res.ok) throw new Error(res.statusText); + return data; +} diff --git a/projects/cloudflare-agents/onboard/tsconfig.json b/projects/cloudflare-agents/onboard/tsconfig.json new file mode 100644 index 0000000..b0771fe --- /dev/null +++ b/projects/cloudflare-agents/onboard/tsconfig.json @@ -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"] +} diff --git a/projects/cloudflare-agents/onboard/wrangler.toml b/projects/cloudflare-agents/onboard/wrangler.toml new file mode 100644 index 0000000..b4fb792 --- /dev/null +++ b/projects/cloudflare-agents/onboard/wrangler.toml @@ -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"] diff --git a/projects/finance/deploy/vm123-finance-stack/traefik-routes-snippet.yml b/projects/finance/deploy/vm123-finance-stack/traefik-routes-snippet.yml index c46d4f6..c11a749 100644 --- a/projects/finance/deploy/vm123-finance-stack/traefik-routes-snippet.yml +++ b/projects/finance/deploy/vm123-finance-stack/traefik-routes-snippet.yml @@ -23,6 +23,7 @@ http: entryPoints: [websecure] tls: certResolver: letsencrypt + middlewares: [openpanel-headers] service: vm123-openpanel openpanel-admin: rule: Host(`admin.openpanel.ligbox.com.br`) @@ -41,6 +42,11 @@ http: X-Forwarded-Proto: https X-Forwarded-Host: financeiro.ligbox.com.br X-Script-Name: /odoo + openpanel-headers: + headers: + customRequestHeaders: + X-Forwarded-Proto: https + X-Forwarded-Host: openpanel.ligbox.com.br services: vm123-foss: @@ -54,7 +60,7 @@ http: vm123-openpanel: loadBalancer: servers: - - url: https://10.10.10.123:2083 + - url: http://10.10.10.123:2083 vm123-openadmin: loadBalancer: servers: diff --git a/projects/ops-desk/api/app/auth_routes.py b/projects/ops-desk/api/app/auth_routes.py index 5ab3d9e..2e28500 100644 --- a/projects/ops-desk/api/app/auth_routes.py +++ b/projects/ops-desk/api/app/auth_routes.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, Field 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 router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) @@ -277,3 +277,42 @@ def reset_user_2fa( email = row["email"] or target mail_notify.notify_admin_2fa_reset(target, email, user.username) return {"ok": True, "message": f"2FA resetado para {target}. O utilizador pode reconfigurar no login."} + + +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, + } diff --git a/projects/ops-desk/api/app/console_handoff.py b/projects/ops-desk/api/app/console_handoff.py new file mode 100644 index 0000000..e1c5263 --- /dev/null +++ b/projects/ops-desk/api/app/console_handoff.py @@ -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] diff --git a/projects/ops-desk/api/app/dns_viewer.py b/projects/ops-desk/api/app/dns_viewer.py index a81251c..e5a1733 100644 --- a/projects/ops-desk/api/app/dns_viewer.py +++ b/projects/ops-desk/api/app/dns_viewer.py @@ -13,6 +13,8 @@ from app.collectors.dns import _dig, collect from app.openpanel_dns import ( fetch_openpanel_bind_records, 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") @@ -194,16 +196,27 @@ def _edit_links( role: str, authoritative: str = "", ) -> 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]] = [] if can_open_cloudflare_dns_link(role) and dns_mode.startswith("ligbox_cf"): 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( { - "label": "Editar na Cloudflare (Ligbox)", + "label": "Editar na Cloudflare (Ligbox staff)", "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"], } ) @@ -212,22 +225,39 @@ def _edit_links( { "label": "Registro.br / registrador", "provider": "registrar", + "audience": "all", "url": "https://registro.br", "roles": ["super_admin", "ops_lead", "technician", "seo"], } ) if dns_mode == "openpanel_bind" or authoritative == "openpanel_bind": - from app.permissions import can_open_openpanel_link - - if can_open_openpanel_link(role): - from app.openpanel_dns import OPENPANEL_URL - + if can_open_openpanel_admin_link(role): links.append( { - "label": "Editar no OpenPanel", - "provider": "openpanel_bind", - "url": f"{OPENPANEL_URL}/domains/{domain}/dns", - "roles": ["super_admin", "ops_lead", "sales_admin", "seo"], + "label": "Editar DNS (OpenAdmin)", + "provider": "openpanel_admin", + "audience": "staff", + "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 @@ -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 []) + from app.permissions import can_open_openpanel_admin_link + return { "domain": domain, "dns_mode": dns_mode, @@ -331,7 +363,11 @@ async def fetch_dns_viewer( "bind_host": op_bind.get("bind_host"), "zone_responds": op_bind.get("zone_responds"), "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, "email_service": cf_payload.get("email_service"), diff --git a/projects/ops-desk/api/app/domain_console_routes.py b/projects/ops-desk/api/app/domain_console_routes.py index c11f65a..b2de50c 100644 --- a/projects/ops-desk/api/app/domain_console_routes.py +++ b/projects/ops-desk/api/app/domain_console_routes.py @@ -51,7 +51,10 @@ async def domain_console_dns_viewer( ) if user.role not in STAFF_ROLES: 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 diff --git a/projects/ops-desk/api/app/openpanel_dns.py b/projects/ops-desk/api/app/openpanel_dns.py index 7397062..966d264 100644 --- a/projects/ops-desk/api/app/openpanel_dns.py +++ b/projects/ops-desk/api/app/openpanel_dns.py @@ -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_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 OPENPANEL_NS_HINTS = ("openpanel", "ligbox", "itecnologys") @@ -61,6 +74,18 @@ def ns_points_to_openpanel(domain: str) -> bool: 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: host = OPENPANEL_BIND_HOST if not host: @@ -133,6 +158,7 @@ def fetch_openpanel_bind_records(domain: str) -> dict[str, Any]: "total": len(rows), "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", } diff --git a/projects/ops-desk/api/app/permissions.py b/projects/ops-desk/api/app/permissions.py index a7c81a5..3d14e25 100644 --- a/projects/ops-desk/api/app/permissions.py +++ b/projects/ops-desk/api/app/permissions.py @@ -134,7 +134,27 @@ def can_open_cloudflare_dns_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: diff --git a/projects/ops-desk/frontend/assets/dns-viewer.js b/projects/ops-desk/frontend/assets/dns-viewer.js index 6a70805..25d391a 100644 --- a/projects/ops-desk/frontend/assets/dns-viewer.js +++ b/projects/ops-desk/frontend/assets/dns-viewer.js @@ -108,9 +108,23 @@ }).join(''); } - function editLinksHtml(links) { - if (!links?.length) return ''; - return `
${links.map((l) => `${esc(l.label)} ↗`).join(' ')}
`; + function editLinksHtml(links, domain) { + const parts = []; + if (typeof canReadDnsViewer === 'function' && canReadDnsViewer() && domain) { + parts.push( + ``, + ); + } + if (links?.length) { + parts.push( + ...links.map( + (l) => + `${esc(l.label)} ↗`, + ), + ); + } + if (!parts.length) return ''; + return `
${parts.join(' ')}
`; } function renderDnsPanel(data, { compact = false, title = 'DNS do domínio' } = {}) { @@ -154,7 +168,7 @@ ${planned} - ${editLinksHtml(data.edit_links)} + ${editLinksHtml(data.edit_links, data.domain)} ${data.errors?.length ? `

${esc(data.errors.join(' · '))}

` : ''}`; if (compact) return inner; diff --git a/projects/wizard/backend/app/routers/project.py b/projects/wizard/backend/app/routers/project.py new file mode 100644 index 0000000..f3983ff --- /dev/null +++ b/projects/wizard/backend/app/routers/project.py @@ -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.", + } diff --git a/projects/wizard/backend/app/services/cf_client_accounts.py b/projects/wizard/backend/app/services/cf_client_accounts.py new file mode 100644 index 0000000..ec2b6a8 --- /dev/null +++ b/projects/wizard/backend/app/services/cf_client_accounts.py @@ -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.", + } diff --git a/projects/wizard/backend/app/services/dns_account_registry.py b/projects/wizard/backend/app/services/dns_account_registry.py new file mode 100644 index 0000000..63c429b --- /dev/null +++ b/projects/wizard/backend/app/services/dns_account_registry.py @@ -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." + ), + } diff --git a/projects/wizard/backend/app/services/dns_byo_vault.py b/projects/wizard/backend/app/services/dns_byo_vault.py new file mode 100644 index 0000000..d5e1055 --- /dev/null +++ b/projects/wizard/backend/app/services/dns_byo_vault.py @@ -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) diff --git a/projects/wizard/backend/app/services/project_identity.py b/projects/wizard/backend/app/services/project_identity.py new file mode 100644 index 0000000..fbfb7bc --- /dev/null +++ b/projects/wizard/backend/app/services/project_identity.py @@ -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) diff --git a/specs/004-onboard-funnel-events/cloudflare-zone-provision.md b/specs/004-onboard-funnel-events/cloudflare-zone-provision.md new file mode 100644 index 0000000..095a0dc --- /dev/null +++ b/specs/004-onboard-funnel-events/cloudflare-zone-provision.md @@ -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": "" }, + "type": "full", + "jump_start": false +} +``` + +--- + +## Endpoints API (wizard — passo DNS onboard) + +Base: `/api/onboarding` · Header obrigatório: `X-Onboarding-Session: ` + +| 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`). diff --git a/specs/004-onboard-funnel-events/spec.md b/specs/004-onboard-funnel-events/spec.md index fd9a171..22e2f92 100644 --- a/specs/004-onboard-funnel-events/spec.md +++ b/specs/004-onboard-funnel-events/spec.md @@ -10,7 +10,15 @@ **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) diff --git a/specs/004-onboard-funnel-events/tasks.md b/specs/004-onboard-funnel-events/tasks.md index d900918..93cd013 100644 --- a/specs/004-onboard-funnel-events/tasks.md +++ b/specs/004-onboard-funnel-events/tasks.md @@ -11,7 +11,7 @@ ## Phase B — Portal VM112 - [ ] 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` - [ ] T009 `create_account` except: emit `onboarding.failed` - [ ] T010 Sync código para `/opt/ibytera-mail-portal/` VM112 + restart diff --git a/specs/019-ops-console-active-operations/deploy/frontend/src/components/AdminLoginGate.jsx b/specs/019-ops-console-active-operations/deploy/frontend/src/components/AdminLoginGate.jsx new file mode 100644 index 0000000..27f3d7c --- /dev/null +++ b/specs/019-ops-console-active-operations/deploy/frontend/src/components/AdminLoginGate.jsx @@ -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 ( +
+

Área Admin

+

+ Se já entrou no Desk neste browser, actualize esta página (F5) — a sessão + partilha automaticamente entre desk e console. + Alternativa: no painel DNS do setup, Abrir no Console ↗. + Só precisa de password aqui se abriu o Console directamente, sem Desk. +

+
+ + + {error ?

{error}

: null} + +
+
+ ) +} diff --git a/specs/019-ops-console-active-operations/deploy/frontend/src/layouts/AdminShell.jsx b/specs/019-ops-console-active-operations/deploy/frontend/src/layouts/AdminShell.jsx index afd4534..c1b3383 100644 --- a/specs/019-ops-console-active-operations/deploy/frontend/src/layouts/AdminShell.jsx +++ b/specs/019-ops-console-active-operations/deploy/frontend/src/layouts/AdminShell.jsx @@ -1,13 +1,57 @@ +import { useEffect, useState } from 'react' 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 = [ { to: '/admin', label: 'Início', end: true }, { 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() { + const [authReady, setAuthReady] = useState(false) + const [authed, setAuthed] = useState(isLoggedIn()) 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 (
@@ -16,7 +60,7 @@ export default function AdminShell() {

Ligbox Console

-

Gerente de domínio

+

{subtitle}

Administração

@@ -36,17 +80,31 @@ export default function AdminShell() { - {user ? ( + {authed && user ? (

{user.display_name || user.username} + {user.role ? ` · ${user.role}` : ''}

) : null} - + {authed ? ( + + ) : null}
- + {!authReady ? ( +

A validar sessão…

+ ) : !authed ? ( + + ) : ( + + )}
) diff --git a/specs/019-ops-console-active-operations/deploy/frontend/src/lib/api.js b/specs/019-ops-console-active-operations/deploy/frontend/src/lib/api.js index ff2b552..44f8da1 100644 --- a/specs/019-ops-console-active-operations/deploy/frontend/src/lib/api.js +++ b/specs/019-ops-console-active-operations/deploy/frontend/src/lib/api.js @@ -1,9 +1,24 @@ 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 } = {}) { const headers = { Accept: 'application/json' } 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) { const err = new Error(`HTTP ${res.status}`) err.status = res.status @@ -15,12 +30,16 @@ export async function apiGet(path, { token } = {}) { export async function apiPost(path, body, { token } = {}) { const headers = { Accept: 'application/json', 'Content-Type': 'application/json' } if (token) headers.Authorization = `Bearer ${token}` - const res = await fetch(`${API_BASE}${path}`, { - method: 'POST', - headers, - credentials: 'include', - body: JSON.stringify(body), - }) + let res + try { + res = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + } catch (e) { + throw wrapNetworkError(e) + } const data = await res.json().catch(() => ({})) if (!res.ok) { const err = new Error(data.detail || data.message || `HTTP ${res.status}`) diff --git a/specs/019-ops-console-active-operations/deploy/frontend/src/lib/auth.js b/specs/019-ops-console-active-operations/deploy/frontend/src/lib/auth.js index f1299fe..ad4aec4 100644 --- a/specs/019-ops-console-active-operations/deploy/frontend/src/lib/auth.js +++ b/specs/019-ops-console-active-operations/deploy/frontend/src/lib/auth.js @@ -1,5 +1,22 @@ const TOKEN_KEY = 'ligbox_console_token' 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() { return localStorage.getItem(TOKEN_KEY) @@ -27,12 +44,92 @@ export function isLoggedIn() { 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) { const { apiPost } = await import('./api') const data = await apiPost('/api/v1/auth/login', { username, password }) 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 } diff --git a/specs/019-ops-console-active-operations/deploy/frontend/src/views/admin/AdminDominio.jsx b/specs/019-ops-console-active-operations/deploy/frontend/src/views/admin/AdminDominio.jsx index 8ca142f..9f57b81 100644 --- a/specs/019-ops-console-active-operations/deploy/frontend/src/views/admin/AdminDominio.jsx +++ b/specs/019-ops-console-active-operations/deploy/frontend/src/views/admin/AdminDominio.jsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { useSearchParams } from 'react-router-dom' import DnsViewerPanel from '../../components/DnsViewerPanel' 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() { const [params, setParams] = useSearchParams() @@ -10,16 +10,19 @@ export default function AdminDominio() { const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [loginError, setLoginError] = useState('') + const user = getUser() + const canRead = user && canReadDnsViewer(user.role) const loadDns = useCallback(async (dom) => { const d = (dom || '').trim().toLowerCase() 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() if (!token) { - setError('Faça login para ver o DNS.') + setError('Sessão inválida — volte a entrar na área Admin.') return } setLoading(true) @@ -32,6 +35,16 @@ export default function AdminDominio() { setData(payload) } catch (e) { 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') } finally { setLoading(false) @@ -42,20 +55,9 @@ export default function AdminDominio() { const q = params.get('domain') if (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) { e.preventDefault() @@ -63,32 +65,37 @@ export default function AdminDominio() { loadDns(domain) } + if (!canRead) { + return ( +
+

Domínio & DNS

+

+ O perfil {user?.role || 'desconhecido'} não inclui consulta DNS. + Peça acesso na matriz Spec 027 ou use outro menu conforme o seu role. +

+
+ ) + } + return (

Domínio & DNS

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

- {!isLoggedIn() ? ( -
-

Login Desk

-

Use credenciais Ligbox Ops (staff) ou gerente.

- - - {loginError ?

{loginError}

: null} - -
- ) : null} - -
+