From 038fb8f7ce571127b35855c4a086b069c66d00ca Mon Sep 17 00:00:00 2001 From: Ligbox Spec Hub Date: Thu, 25 Jun 2026 16:36:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(dns):=20Spec=20037=20DNS=20Viewer=20?= =?UTF-8?q?=E2=80=94=20Desk,=20Console=20e=20patches=20Wizard=20V4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entrega read-only de apontamentos DNS (Cloudflare, OpenPanel BIND, público) no Desk e Console /admin/dominio, com spec, scripts de rollback e patches VM112 para painel lateral no passo DNS do onboarding (deploy wizard pendente). Co-authored-by: Cursor --- deploy/vm112-wizard/DNS-VIEWER-V4.md | 36 + .../vm112-wizard/deploy-037-construction.py | 134 ++++ deploy/vm112-wizard/deploy-037-vm112.py | 125 ++++ deploy/vm112-wizard/dns-accounts.yaml.example | 36 + deploy/vm112-wizard/fix-choose-portal-dns.py | 58 ++ deploy/vm112-wizard/fix-provision-zone.py | 160 ++++ .../frontend-dns-viewer-v4.patch.py | 123 ++++ deploy/vm112-wizard/frontend-dns037.patch.py | 315 ++++++++ .../frontend-provision037b.patch.py | 165 +++++ .../onboarding-dns-viewer-v4.patch.py | 56 ++ .../vm112-wizard/onboarding-dns037.patch.py | 341 +++++++++ .../vm112-wizard/patch-wizard-project-ui.py | 77 ++ .../vm112-wizard/provision-zone037b.patch.py | 119 +++ projects/ops-desk/api/app/dns_viewer.py | 345 +++++++++ .../ops-desk/api/app/domain_console_routes.py | 66 ++ .../api/app/domain_console_sandbox.py | 316 ++++++++ .../api/app/domain_console_sandbox_routes.py | 175 +++++ .../api/app/domain_console_sandbox_store.py | 134 ++++ projects/ops-desk/api/app/main.py | 43 ++ projects/ops-desk/api/app/openpanel_dns.py | 138 ++++ projects/ops-desk/api/app/permissions.py | 13 + projects/ops-desk/frontend/assets/app.js | 7 +- .../ops-desk/frontend/assets/dns-viewer.js | 204 +++++ .../deploy/frontend/src/App.jsx | 25 +- .../src/components/DnsViewerPanel.jsx | 132 ++++ .../frontend/src/layouts/AdminShell.jsx | 53 ++ .../deploy/frontend/src/lib/api.js | 20 + .../deploy/frontend/src/lib/auth.js | 38 + .../deploy/frontend/src/theme.css | 21 + .../frontend/src/views/admin/AdminDominio.jsx | 109 +++ .../frontend/src/views/admin/AdminHome.jsx | 17 + .../domain-manager-console-ui.md | 416 +++++++++++ .../foss-products.md | 144 ++++ .../ligbox-console-shell.md | 306 ++++++++ .../ligbox-system-admin-ui.md | 213 ++++++ .../mockups/DEMO-CREDENCIAIS.md | 21 + .../mockups/demo-domain-manager-redirect.html | 85 +++ .../mockups/domain-manager-sandbox.html | 632 ++++++++++++++++ .../spec.md | 696 ++++++++++++++++++ .../tasks.md | 92 +++ .../user-self-service-ui.md | 75 ++ .../cf-agents-sdk-architecture.md | 167 +++++ .../client-cf-account-lifecycle.md | 149 ++++ .../deploy/DNS-VIEWER-EXEC-20260625.md | 110 +++ .../deploy/DNS-VIEWER-ROLLBACK.md | 225 ++++++ .../deploy/scripts/preflight-dns-viewer.sh | 100 +++ .../deploy/scripts/rollback-dns-viewer.sh | 103 +++ .../deploy/scripts/verify-dns-viewer.sh | 92 +++ .../dns-viewer.md | 369 ++++++++++ .../implementation-phases.md | 90 +++ .../project-email-identity.md | 118 +++ .../spec.md | 178 +++++ .../tasks.md | 57 ++ .../wizard-validation-checklist.md | 40 + 54 files changed, 8074 insertions(+), 5 deletions(-) create mode 100644 deploy/vm112-wizard/DNS-VIEWER-V4.md create mode 100644 deploy/vm112-wizard/deploy-037-construction.py create mode 100644 deploy/vm112-wizard/deploy-037-vm112.py create mode 100644 deploy/vm112-wizard/dns-accounts.yaml.example create mode 100644 deploy/vm112-wizard/fix-choose-portal-dns.py create mode 100644 deploy/vm112-wizard/fix-provision-zone.py create mode 100644 deploy/vm112-wizard/frontend-dns-viewer-v4.patch.py create mode 100644 deploy/vm112-wizard/frontend-dns037.patch.py create mode 100644 deploy/vm112-wizard/frontend-provision037b.patch.py create mode 100644 deploy/vm112-wizard/onboarding-dns-viewer-v4.patch.py create mode 100644 deploy/vm112-wizard/onboarding-dns037.patch.py create mode 100644 deploy/vm112-wizard/patch-wizard-project-ui.py create mode 100644 deploy/vm112-wizard/provision-zone037b.patch.py create mode 100644 projects/ops-desk/api/app/dns_viewer.py create mode 100644 projects/ops-desk/api/app/domain_console_routes.py create mode 100644 projects/ops-desk/api/app/domain_console_sandbox.py create mode 100644 projects/ops-desk/api/app/domain_console_sandbox_routes.py create mode 100644 projects/ops-desk/api/app/domain_console_sandbox_store.py create mode 100644 projects/ops-desk/api/app/openpanel_dns.py create mode 100644 projects/ops-desk/frontend/assets/dns-viewer.js create mode 100644 specs/019-ops-console-active-operations/deploy/frontend/src/components/DnsViewerPanel.jsx create mode 100644 specs/019-ops-console-active-operations/deploy/frontend/src/layouts/AdminShell.jsx create mode 100644 specs/019-ops-console-active-operations/deploy/frontend/src/lib/auth.js create mode 100644 specs/019-ops-console-active-operations/deploy/frontend/src/views/admin/AdminDominio.jsx create mode 100644 specs/019-ops-console-active-operations/deploy/frontend/src/views/admin/AdminHome.jsx create mode 100644 specs/035-ligbox-mail-bundles-foss-openpanel/domain-manager-console-ui.md create mode 100644 specs/035-ligbox-mail-bundles-foss-openpanel/foss-products.md create mode 100644 specs/035-ligbox-mail-bundles-foss-openpanel/ligbox-console-shell.md create mode 100644 specs/035-ligbox-mail-bundles-foss-openpanel/ligbox-system-admin-ui.md create mode 100644 specs/035-ligbox-mail-bundles-foss-openpanel/mockups/DEMO-CREDENCIAIS.md create mode 100644 specs/035-ligbox-mail-bundles-foss-openpanel/mockups/demo-domain-manager-redirect.html create mode 100644 specs/035-ligbox-mail-bundles-foss-openpanel/mockups/domain-manager-sandbox.html create mode 100644 specs/035-ligbox-mail-bundles-foss-openpanel/spec.md create mode 100644 specs/035-ligbox-mail-bundles-foss-openpanel/tasks.md create mode 100644 specs/035-ligbox-mail-bundles-foss-openpanel/user-self-service-ui.md create mode 100644 specs/037-dns-multi-cloudflare-orchestration/cf-agents-sdk-architecture.md create mode 100644 specs/037-dns-multi-cloudflare-orchestration/client-cf-account-lifecycle.md create mode 100644 specs/037-dns-multi-cloudflare-orchestration/deploy/DNS-VIEWER-EXEC-20260625.md create mode 100644 specs/037-dns-multi-cloudflare-orchestration/deploy/DNS-VIEWER-ROLLBACK.md create mode 100755 specs/037-dns-multi-cloudflare-orchestration/deploy/scripts/preflight-dns-viewer.sh create mode 100755 specs/037-dns-multi-cloudflare-orchestration/deploy/scripts/rollback-dns-viewer.sh create mode 100755 specs/037-dns-multi-cloudflare-orchestration/deploy/scripts/verify-dns-viewer.sh create mode 100644 specs/037-dns-multi-cloudflare-orchestration/dns-viewer.md create mode 100644 specs/037-dns-multi-cloudflare-orchestration/implementation-phases.md create mode 100644 specs/037-dns-multi-cloudflare-orchestration/project-email-identity.md create mode 100644 specs/037-dns-multi-cloudflare-orchestration/spec.md create mode 100644 specs/037-dns-multi-cloudflare-orchestration/tasks.md create mode 100644 specs/037-dns-multi-cloudflare-orchestration/wizard-validation-checklist.md diff --git a/deploy/vm112-wizard/DNS-VIEWER-V4.md b/deploy/vm112-wizard/DNS-VIEWER-V4.md new file mode 100644 index 0000000..d65c54e --- /dev/null +++ b/deploy/vm112-wizard/DNS-VIEWER-V4.md @@ -0,0 +1,36 @@ +# Spec 037 V4 — Wizard DNS Viewer + +Painel **read-only** no **passo DNS existente** (step 1) — **não** é menu nem página nova. + +## Backend + +```bash +# Na VM112 +python3 /opt/ligbox-ops-platform/deploy/vm112-wizard/onboarding-dns-viewer-v4.patch.py +# Requer OPS_INTERNAL_TOKEN + DESK_API_URL no env wizard +systemctl restart ligbox-wizard # ou docker compose restart backend +``` + +Endpoint: `GET /api/onboarding/dns/viewer/{domain}` +Header: `X-Onboarding-Session` (sessão wizard) + +Proxy → Desk `GET /api/v1/dns/viewer/{domain}` (token interno). +`edit_links` removidos na resposta wizard (cliente). + +## Frontend + +```bash +python3 /opt/ligbox-ops-platform/deploy/vm112-wizard/frontend-dns-viewer-v4.patch.py +cd /opt/ligbox-wizard/frontend && npm run build +``` + +## UI + +Painel lateral abaixo das opções DNS: +- Modo (Ligbox / externo / OpenPanel) +- Tabela registos (max 12 linhas) +- Secção «Serão aplicados» se `planned_records` + +## Rollback + +Ver `specs/037-dns-multi-cloudflare-orchestration/deploy/DNS-VIEWER-ROLLBACK.md` § VM112. diff --git a/deploy/vm112-wizard/deploy-037-construction.py b/deploy/vm112-wizard/deploy-037-construction.py new file mode 100644 index 0000000..1774f3c --- /dev/null +++ b/deploy/vm112-wizard/deploy-037-construction.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Deploy Spec 037 construção — Fase A + stubs D na VM112.""" +from pathlib import Path + +BASE = Path("/opt/ligbox-spec-hub/repos/ligbox-ops-platform") +WIZARD = Path("/opt/ligbox-wizard") +REPO = BASE / "projects/wizard/backend/app" + +FILES = [ + (REPO / "services/project_identity.py", WIZARD / "backend/app/services/project_identity.py"), + (REPO / "services/cf_client_accounts.py", WIZARD / "backend/app/services/cf_client_accounts.py"), + (REPO / "routers/project.py", WIZARD / "backend/app/routers/project.py"), +] + +for src, dst in FILES: + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") + print(f"copied {dst.name}") + +# main.py — register project router +main = WIZARD / "backend/app/main.py" +text = main.read_text(encoding="utf-8") +if "project" not in text.split("from app.routers import")[1].split("\n")[0]: + text = text.replace( + "from app.routers import admin_accounts, admin_domains, assist, corporate, domain_admin, onboarding, portal_auth, telemetry", + "from app.routers import admin_accounts, admin_domains, assist, corporate, domain_admin, onboarding, portal_auth, project, telemetry", + ) +if "project.router" not in text: + text = text.replace( + "app.include_router(onboarding.router, prefix=\"/api\")", + "app.include_router(onboarding.router, prefix=\"/api\")\napp.include_router(project.router, prefix=\"/api\")", + ) +main.write_text(text, encoding="utf-8") +print("main.py patched") + +# onboarding.py — phase D endpoints + webhook ligbox_project_email +onb = WIZARD / "backend/app/routers/onboarding.py" +ot = onb.read_text(encoding="utf-8") + +if "cf_client_accounts" not in ot: + ot = ot.replace( + "from app.services import dns_byo_vault", + "from app.services import dns_byo_vault, cf_client_accounts, project_identity", + ) + +PHASE_D = ''' + +class ProvisionClientAccountRequest(BaseModel): + domain: str = Field(..., min_length=3, max_length=253) + display_name: str | None = None + + +class HandoffManagerRequest(BaseModel): + domain: str + manager_email: str = Field(..., min_length=5) + + +def _project_email_for_domain(domain: str) -> str | None: + ident = project_identity.get_project_identity(domain.lower().strip()) + return ident.get("ligbox_project_email") if ident else None + + +@router.post("/dns/cloudflare/provision-client-account") +def provision_client_cf_account(body: ProvisionClientAccountRequest, request: Request): + """Fase D — conta CF dedicada (fallback zona partilhada até Tenant).""" + 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: + ligbox_email = _project_email_for_domain(domain) + result = cf_client_accounts.provision_client_account( + domain, + ligbox_project_email=ligbox_email, + account_name=body.display_name or domain, + ) + ops_webhook.emit_event( + "onboard.cf_account.provisioned", + domain=domain, + session_id=get_session_from_request(request), + data={ + "ligbox_project_email": ligbox_email, + "zone_id": result.get("zone", {}).get("id"), + "provision_mode": result.get("record", {}).get("provision_mode"), + }, + ) + return result + except CloudflareError as e: + raise HTTPException(502, str(e)) from e + + +@router.post("/dns/cloudflare/handoff-manager") +def handoff_cf_manager(body: HandoffManagerRequest, request: Request): + """Fase D — convite gestor domínio na conta CF.""" + get_session_from_request(request) + domain = normalize_domain(body.domain) + result = cf_client_accounts.handoff_manager(domain, body.manager_email) + ops_webhook.emit_event( + "onboard.cf_handoff.requested", + domain=domain, + session_id=get_session_from_request(request), + data=result, + ) + return result + +''' + +if "/dns/cloudflare/provision-client-account" not in ot: + anchor = "@router.get(\"/session/password-status\")" + ot = ot.replace(anchor, PHASE_D + anchor) + +# apply webhook +if "onboard.dns.applied" not in ot: + ot = ot.replace( + ' verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)\n return {\n "domain": domain,', + ' verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)\n ops_webhook.emit_event(\n "onboard.dns.applied",\n domain=domain,\n session_id=sid,\n data={\n "dns_mode": dns_mode,\n "ligbox_project_email": _project_email_for_domain(domain),\n "ligbox_account_id": ligbox_acct,\n "zone_id": zone_id,\n "verification_ready": verification.get("ready"),\n },\n )\n return {\n "domain": domain,', + ) + +# account create webhook extension +if "onboard.account.created" not in ot: + ot = ot.replace( + ' activity_log.ok("Processo de onboarding finalizado", source="portal")', + ' ops_webhook.emit_event(\n "onboard.account.created",\n domain=domain,\n session_id=sid,\n data={\n "email": email,\n "dns_mode": dns_mode,\n "ligbox_project_email": _project_email_for_domain(domain),\n "verified": verified,\n },\n )\n activity_log.ok("Processo de onboarding finalizado", source="portal")', + ) + +onb.write_text(ot, encoding="utf-8") +print("onboarding.py patched") + +import ast +ast.parse(main.read_text()) +ast.parse(onb.read_text()) +print("syntax OK") diff --git a/deploy/vm112-wizard/deploy-037-vm112.py b/deploy/vm112-wizard/deploy-037-vm112.py new file mode 100644 index 0000000..dd1b8bc --- /dev/null +++ b/deploy/vm112-wizard/deploy-037-vm112.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Deploy 037 construction on VM112 — run ON the host.""" +from pathlib import Path +import ast + +WIZARD = Path("/opt/ligbox-wizard") +TMP = Path("/tmp") + +for name in ["project_identity.py", "cf_client_accounts.py"]: + dst = WIZARD / "backend/app/services" / name + dst.write_text((TMP / name).read_text(encoding="utf-8"), encoding="utf-8") + print("copied", name) + +(WIZARD / "backend/app/routers/project.py").write_text( + (TMP / "project_router.py").read_text(encoding="utf-8"), encoding="utf-8" +) +print("copied project.py") + +main = WIZARD / "backend/app/main.py" +text = main.read_text(encoding="utf-8") +old_imp = ( + "from app.routers import admin_accounts, admin_domains, assist, corporate, " + "domain_admin, onboarding, portal_auth, telemetry" +) +new_imp = old_imp.replace(", telemetry", ", project, telemetry") +if "project, telemetry" not in text: + text = text.replace(old_imp, new_imp) +if "project.router" not in text: + text = text.replace( + 'app.include_router(onboarding.router, prefix="/api")', + 'app.include_router(onboarding.router, prefix="/api")\n' + 'app.include_router(project.router, prefix="/api")', + ) +main.write_text(text, encoding="utf-8") +print("main.py ok") + +onb = WIZARD / "backend/app/routers/onboarding.py" +ot = onb.read_text(encoding="utf-8") +if "cf_client_accounts" not in ot: + ot = ot.replace( + "from app.services import dns_byo_vault", + "from app.services import dns_byo_vault, cf_client_accounts, project_identity", + ) + +PHASE_D = ''' + +class ProvisionClientAccountRequest(BaseModel): + domain: str = Field(..., min_length=3, max_length=253) + display_name: str | None = None + + +class HandoffManagerRequest(BaseModel): + domain: str + manager_email: str = Field(..., min_length=5) + + +def _project_email_for_domain(domain: str) -> str | None: + ident = project_identity.get_project_identity(domain.lower().strip()) + return ident.get("ligbox_project_email") if ident else None + + +@router.post("/dns/cloudflare/provision-client-account") +def provision_client_cf_account(body: ProvisionClientAccountRequest, request: Request): + 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: + ligbox_email = _project_email_for_domain(domain) + result = cf_client_accounts.provision_client_account( + domain, + ligbox_project_email=ligbox_email, + account_name=body.display_name or domain, + ) + ops_webhook.emit_event( + "onboard.cf_account.provisioned", + domain=domain, + session_id=get_session_from_request(request), + data={ + "ligbox_project_email": ligbox_email, + "zone_id": result.get("zone", {}).get("id"), + "provision_mode": result.get("record", {}).get("provision_mode"), + }, + ) + return result + except CloudflareError as e: + raise HTTPException(502, str(e)) from e + + +@router.post("/dns/cloudflare/handoff-manager") +def handoff_cf_manager(body: HandoffManagerRequest, request: Request): + get_session_from_request(request) + domain = normalize_domain(body.domain) + result = cf_client_accounts.handoff_manager(domain, body.manager_email) + ops_webhook.emit_event( + "onboard.cf_handoff.requested", + domain=domain, + session_id=get_session_from_request(request), + data=result, + ) + return result + +''' + +if "/dns/cloudflare/provision-client-account" not in ot: + ot = ot.replace('@router.get("/session/password-status")', PHASE_D + '@router.get("/session/password-status")') + +if "onboard.dns.applied" not in ot: + ot = ot.replace( + ' verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)\n return {\n "domain": domain,', + ' verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)\n ops_webhook.emit_event(\n "onboard.dns.applied",\n domain=domain,\n session_id=sid,\n data={\n "dns_mode": dns_mode,\n "ligbox_project_email": _project_email_for_domain(domain),\n "ligbox_account_id": ligbox_acct,\n "zone_id": zone_id,\n "verification_ready": verification.get("ready"),\n },\n )\n return {\n "domain": domain,', + ) + +if "onboard.account.created" not in ot: + ot = ot.replace( + ' activity_log.ok("Processo de onboarding finalizado", source="portal")', + ' ops_webhook.emit_event(\n "onboard.account.created",\n domain=domain,\n session_id=sid,\n data={\n "email": email,\n "dns_mode": dns_mode,\n "ligbox_project_email": _project_email_for_domain(domain),\n "verified": verified,\n },\n )\n activity_log.ok("Processo de onboarding finalizado", source="portal")', + ) + +onb.write_text(ot, encoding="utf-8") +ast.parse(main.read_text()) +ast.parse(onb.read_text()) +print("deploy-037-vm112 OK") diff --git a/deploy/vm112-wizard/dns-accounts.yaml.example b/deploy/vm112-wizard/dns-accounts.yaml.example new file mode 100644 index 0000000..48a4823 --- /dev/null +++ b/deploy/vm112-wizard/dns-accounts.yaml.example @@ -0,0 +1,36 @@ +# Spec 037 — Catálogo contas Cloudflare Ligbox (VM112) +# Copiar para /opt/ligbox-wizard/dns-accounts.yaml +# Tokens: secrets/cloudflare-{id}.token (NÃO commitar) + +version: "1.0" +probe_order: + - ligit + - itecnologys + - ibytera + +# Conta onde o wizard cria zonas NOVAS de clientes (caminho Cloudflare Ligbox) +default_provision_account: ibytera + +accounts: + - id: ligit + label: "Ligbox Ligit" + admin_email: admin@ligit.com.br + cloudflare_account_id: "PREENCHER_ACCOUNT_ID_CF" + token_file: secrets/cloudflare-ligit.token + + - id: itecnologys + label: "Ligbox iTecnologys" + admin_email: admin@itecnologys.com + cloudflare_account_id: "PREENCHER_ACCOUNT_ID_CF" + token_file: secrets/cloudflare-itecnologys.token + + - id: ibytera + label: "Ligbox Ibytera" + admin_email: ibytera@gmail.com + cloudflare_account_id: "2d504d7a78dd787696fe1c703fb001c1" + token_file: secrets/cloudflare-ibytera.token + # Migrar token actual de secrets/cloudflare.token + +# Legacy fallback (remover após migração) +legacy_token_file: secrets/cloudflare.token +legacy_account_id: "2d504d7a78dd787696fe1c703fb001c1" diff --git a/deploy/vm112-wizard/fix-choose-portal-dns.py b/deploy/vm112-wizard/fix-choose-portal-dns.py new file mode 100644 index 0000000..2ea550c --- /dev/null +++ b/deploy/vm112-wizard/fix-choose-portal-dns.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +from pathlib import Path +import re + +APP = Path("/opt/ligbox-wizard/frontend/src/App.jsx") +text = APP.read_text(encoding="utf-8") + +pattern = r" async function choosePortalDns\(\) \{.*?\n \}\n\n async function chooseByoDns" +replacement = r''' async function choosePortalDns() { + setDnsChoice('portal') + setError(null) + startBusy('dns_zone') + try { + const resolved = dnsResolve || (await api(`/onboarding/dns/resolve/${domain}`)) + setDnsResolve(resolved) + const guide = await api('/onboarding/dns/cloudflare/provision-zone', { + method: 'POST', + body: JSON.stringify({ domain }), + }) + setPortalGuide(guide) + const cf = guide.status || (await refreshCfStatus()) + setCfStatus(cf) + markActionDone('choosePortalDns') + if (guide.verification) { + setVerification(guide.verification) + setResult({ type: 'dns', data: { verification: guide.verification } }) + markActionDone('verifyZone') + markActionDone('applyPortalDns') + } else if (cf?.zone_in_account && cf?.can_apply_mail_records) { + markActionDone('verifyZone') + const data = await api('/onboarding/dns/cloudflare/apply', { + method: 'POST', + body: JSON.stringify({ + domain, + zone_id: guide.zone_id || resolved.zone_id, + mail_aliases: sanitizeMailAliases(mailAliasInputs, domain), + }), + }) + setVerification(data.verification) + setResult({ type: 'dns', data }) + markActionDone('applyPortalDns') + } + } catch (e) { + setError(e.message) + setDnsChoice(null) + setActionsDone((prev) => ({ ...prev, choosePortalDns: false })) + } finally { + stopBusy() + } + } + + async function chooseByoDns''' + +new_text, n = re.subn(pattern, replacement, text, count=1, flags=re.DOTALL) +if n != 1: + raise SystemExit(f"replace failed: {n}") +APP.write_text(new_text, encoding="utf-8") +print("choosePortalDns fixed") diff --git a/deploy/vm112-wizard/fix-provision-zone.py b/deploy/vm112-wizard/fix-provision-zone.py new file mode 100644 index 0000000..6716c85 --- /dev/null +++ b/deploy/vm112-wizard/fix-provision-zone.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Replace provision_cloudflare_zone body on VM112.""" +from pathlib import Path + +ROUTER = Path("/opt/ligbox-wizard/backend/app/routers/onboarding.py") +CF = Path("/opt/ligbox-wizard/backend/app/services/cloudflare.py") +text = ROUTER.read_text(encoding="utf-8") + +# imports +if "provision_ligbox_zone" not in text: + text = text.replace( + "from app.services.dns_account_registry import resolve_ligbox_zone, resolve_payload", + "from app.services.dns_account_registry import (\n" + " pick_provision_account,\n" + " provision_ligbox_zone,\n" + " resolve_ligbox_zone,\n" + " resolve_payload,\n" + ")", + ) + +OLD_START = '@router.post("/dns/cloudflare/provision-zone")\ndef provision_cloudflare_zone(body: DomainRequest, request: Request):' +OLD_END = '@router.get("/dns/portal-onboarding/{domain}")' + +start = text.find(OLD_START) +end = text.find(OLD_END) +if start < 0 or end < 0: + raise SystemExit("anchors not found") + +NEW_FUNC = '''@router.post("/dns/cloudflare/provision-zone") +def provision_cloudflare_zone(body: DomainRequest, request: Request): + 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 + + match = resolve_ligbox_zone(domain) + if match: + activity_log.info( + f"Zona {domain} já na conta {match.account.id} — sem create", + source="cloudflare", + ) + payload = _portal_onboarding_payload(domain, zone_created=False) + payload["zone_id"] = match.zone_id + payload["ligbox_account_id"] = match.account.id + payload["dns_mode"] = match.dns_mode + payload["message"] = ( + f"Domínio {domain} já está na Cloudflare Ligbox ({match.account.label})." + ) + if nameservers := wizard_nameservers(match.zone): + payload["nameservers"] = nameservers + payload["status"]["nameservers"] = nameservers + return payload + + provision_acct = pick_provision_account() + if not provision_acct: + raise HTTPException( + 400, + "Nenhuma conta Cloudflare Ligbox configurada. Ver dns-accounts.yaml e secrets/.", + ) + + try: + activity_log.info( + f"Criar zona cliente {domain} na CF Ligbox ({provision_acct.id})", + source="cloudflare", + ) + provision_acct.client().verify_token() + prov = provision_ligbox_zone(domain, account=provision_acct) + ligbox_acct = prov["account"] + cf = ligbox_acct.client() + zone = prov["zone"] + created = prov["created"] + sandbox = is_cloudflare_sandbox(cf.token) + if sandbox: + activity_log.info( + "Cloudflare sandbox: zona simulada (token de teste — não cria zona real)", + source="cloudflare", + ) + activity_log.ok( + f"Zona {domain} {'criada' if created else 'já existia'} (id {zone.get('id', '?')})", + source="cloudflare", + ) + payload = _portal_onboarding_payload(domain, zone_created=created) + payload["zone_id"] = zone.get("id") + payload["ligbox_account_id"] = ligbox_acct.id + payload["dns_mode"] = f"ligbox_cf_{ligbox_acct.id}" + payload["sandbox"] = sandbox + payload["message"] = ( + f"Domínio {domain} {'criado' if created else 'já existia'} na Cloudflare Ligbox " + f"({ligbox_acct.label}). " + + ( + "Ambiente de teste: apontamentos simulados automaticamente." + if sandbox + else "Altere os nameservers no registrador." + ) + ) + if nameservers := wizard_nameservers(zone): + payload["nameservers"] = nameservers + payload["status"]["nameservers"] = nameservers + if sandbox: + zone_id = zone.get("id") + applied = [] + for rec in mail_dns_records(domain, settings.mail_public_ip, []): + upsert = cf.upsert_record( + zone_id, + rec["type"], + rec["name"], + rec["content"], + priority=rec.get("priority"), + proxied=rec.get("proxied", False), + zone_name=domain, + ) + applied.append({"type": rec["type"], "name": rec["name"], "id": upsert.get("id")}) + verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip) + payload["applied"] = applied + payload["verification"] = verification + for step in payload.get("steps") or []: + if step.get("order") == 3: + step["done"] = True + step["detail"] = "Apontamentos simulados (sandbox)." + activity_log.ok("Cloudflare sandbox: apontamentos de email simulados", source="cloudflare") + return payload + except CloudflareError as e: + activity_log.error(f"Provision zona falhou: {e}", source="cloudflare") + raise HTTPException( + status_code=502, + detail=cloudflare_error_http_detail(e, domain), + ) from e + + +''' + +text = text[:start] + NEW_FUNC + text[end:] +ROUTER.write_text(text, encoding="utf-8") + +cf_text = CF.read_text(encoding="utf-8") +if "account_id: str | None = None" not in cf_text: + cf_text = cf_text.replace( + ' def ensure_zone(self, domain: str) -> dict:\n' + ' """Garante que a zona existe na conta Ibytera; cria se necessário."""\n' + " zone = self.get_zone_by_name(domain)\n" + " if zone:\n" + ' return {"zone": zone, "created": False}\n' + " zone = self.create_zone(domain)\n" + ' return {"zone": zone, "created": True}', + ' def ensure_zone(self, domain: str, account_id: str | None = None) -> dict:\n' + ' """Garante que a zona existe na conta CF; cria se necessário."""\n' + " zone = self.get_zone_by_name(domain)\n" + " if zone:\n" + ' return {"zone": zone, "created": False}\n' + " zone = self.create_zone(domain, account_id=account_id)\n" + ' return {"zone": zone, "created": True}', + ) + CF.write_text(cf_text, encoding="utf-8") + +import ast + +ast.parse(ROUTER.read_text()) +print("fix-provision-zone OK") diff --git a/deploy/vm112-wizard/frontend-dns-viewer-v4.patch.py b/deploy/vm112-wizard/frontend-dns-viewer-v4.patch.py new file mode 100644 index 0000000..b8cb862 --- /dev/null +++ b/deploy/vm112-wizard/frontend-dns-viewer-v4.patch.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Spec 037 V4 — painel DNS read-only no passo DNS do wizard (App.jsx). Idempotente.""" +from pathlib import Path + +APP = Path("/opt/ligbox-wizard/frontend/src/App.jsx") +text = APP.read_text(encoding="utf-8") + +if "dnsViewerData" in text: + print("App.jsx: dns viewer V4 already patched") + raise SystemExit(0) + +text = text.replace( + " const [showAdvancedDns, setShowAdvancedDns] = useState(false)", + " const [showAdvancedDns, setShowAdvancedDns] = useState(false)\n" + " const [dnsViewerData, setDnsViewerData] = useState(null)\n" + " const [dnsViewerLoading, setDnsViewerLoading] = useState(false)", +) + +LOAD_FN = """ + async function refreshDnsViewer(dom) { + const d = (dom || domain || '').trim().toLowerCase() + if (!d || d.length < 3) return + setDnsViewerLoading(true) + try { + const data = await api(`/onboarding/dns/viewer/${d}`) + setDnsViewerData(data) + } catch { + setDnsViewerData(null) + } finally { + setDnsViewerLoading(false) + } + } + +""" + +if "refreshDnsViewer" not in text: + text = text.replace(" async function choosePortalDns() {", LOAD_FN + " async function choosePortalDns() {") + +# Refresh when instructions load +text = text.replace( + " setDnsResolve(data.resolve || null)", + " setDnsResolve(data.resolve || null)\n refreshDnsViewer(dom)", +) + +text = text.replace( + " setDnsChoice('portal')\n setError(null)\n startBusy('dns_zone')", + " setDnsChoice('portal')\n setError(null)\n refreshDnsViewer(domain)\n startBusy('dns_zone')", +) + +text = text.replace( + " setDnsChoice('external')\n markActionDone('chooseExternalDns')", + " setDnsChoice('external')\n refreshDnsViewer(domain)\n markActionDone('chooseExternalDns')", +) + +PANEL = """ + {(dnsViewerData || dnsViewerLoading) && step === 1 && ( + + )} + +""" + +if "dns-viewer-wizard-panel" not in text: + text = text.replace( + " {dnsChoice === 'byo' && showAdvancedDns && (", + PANEL + " {dnsChoice === 'byo' && showAdvancedDns && (", + ) + +APP.write_text(text, encoding="utf-8") +print("App.jsx: DNS viewer V4 panel OK") diff --git a/deploy/vm112-wizard/frontend-dns037.patch.py b/deploy/vm112-wizard/frontend-dns037.patch.py new file mode 100644 index 0000000..ece394c --- /dev/null +++ b/deploy/vm112-wizard/frontend-dns037.patch.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Patch App.jsx Spec 037 — multi-conta CF + BYO.""" +from pathlib import Path + +APP = Path("/opt/ligbox-wizard/frontend/src/App.jsx") +text = APP.read_text(encoding="utf-8") + +if "chooseByoDns" in text and "dnsResolve" in text: + print("App.jsx already patched") + raise SystemExit(0) + +text = text.replace( + " choosePortalDns: false,\n chooseExternalDns: false,", + " choosePortalDns: false,\n chooseByoDns: false,\n chooseExternalDns: false,", +) + +text = text.replace( + " const [showAdvancedDns, setShowAdvancedDns] = useState(false)", + " const [showAdvancedDns, setShowAdvancedDns] = useState(false)\n const [dnsResolve, setDnsResolve] = useState(null)\n const [byoToken, setByoToken] = useState('')", +) + +OLD_CHOOSE_PORTAL = """ async function choosePortalDns() { + setDnsChoice('portal') + setError(null) + startBusy('dns_zone') + try { + const guide = await api('/onboarding/dns/cloudflare/provision-zone', { + method: 'POST', + body: JSON.stringify({ domain }), + }) + setPortalGuide(guide) + const cf = guide.status || (await refreshCfStatus()) + setCfStatus(cf) + markActionDone('choosePortalDns') + if (guide.verification) { + setVerification(guide.verification) + setResult({ type: 'dns', data: { verification: guide.verification } }) + markActionDone('verifyZone') + markActionDone('applyPortalDns') + } else if (cf?.zone_in_account && cf?.can_apply_mail_records) { + markActionDone('verifyZone') + const data = await api('/onboarding/dns/cloudflare/apply', { + method: 'POST', + body: JSON.stringify({ + domain, + mail_aliases: sanitizeMailAliases(mailAliasInputs, domain), + }), + }) + setVerification(data.verification) + setResult({ type: 'dns', data }) + markActionDone('applyPortalDns') + } + } catch (e) { + setError(e.message) + setDnsChoice(null) + setActionsDone((prev) => ({ ...prev, choosePortalDns: false })) + } finally { + stopBusy() + } + }""" + +NEW_CHOOSE_PORTAL = """ async function choosePortalDns() { + setDnsChoice('portal') + setError(null) + startBusy('dns_zone') + try { + const resolved = dnsResolve || (await api(`/onboarding/dns/resolve/${domain}`)) + setDnsResolve(resolved) + if (!resolved.matched) { + throw new Error( + resolved.message || + 'Domínio não está nas contas Ligbox (ligit, itecnologys, ibytera).', + ) + } + const cf = await refreshCfStatus() + setCfStatus(cf) + markActionDone('choosePortalDns') + markActionDone('verifyZone') + const data = await api('/onboarding/dns/cloudflare/apply', { + method: 'POST', + body: JSON.stringify({ + domain, + zone_id: resolved.zone_id, + mail_aliases: sanitizeMailAliases(mailAliasInputs, domain), + }), + }) + setPortalGuide({ + message: resolved.message, + nameservers: cf?.nameservers || [], + status: cf, + ligbox_account_id: resolved.account_id, + }) + setVerification(data.verification) + setResult({ type: 'dns', data }) + markActionDone('applyPortalDns') + } catch (e) { + setError(e.message) + setDnsChoice(null) + setActionsDone((prev) => ({ ...prev, choosePortalDns: false })) + } finally { + stopBusy() + } + } + + async function chooseByoDns() { + setDnsChoice('byo') + setError(null) + markActionDone('chooseByoDns') + setShowAdvancedDns(true) + } + + async function connectByoAndApply() { + const token = byoToken.trim() + if (!token) { + setError('Cole o API Token da sua conta Cloudflare (DNS Edit na zona).') + return + } + setError(null) + startBusy('dns_byo') + try { + const conn = await api('/onboarding/dns/cloudflare/connect-custom', { + method: 'POST', + body: JSON.stringify({ domain, api_token: token }), + }) + setDnsResolve({ ...conn, matched: true, dns_mode: 'byo_cloudflare' }) + markActionDone('chooseByoDns') + const data = await api('/onboarding/dns/cloudflare/apply', { + method: 'POST', + body: JSON.stringify({ + domain, + zone_id: conn.zone_id, + mail_aliases: sanitizeMailAliases(mailAliasInputs, domain), + }), + }) + setVerification(data.verification) + setResult({ type: 'dns', data }) + markActionDone('applyPortalDns') + } catch (e) { + setError(e.message) + setActionsDone((prev) => ({ ...prev, chooseByoDns: false, applyPortalDns: false })) + } finally { + stopBusy() + } + }""" + +text = text.replace(OLD_CHOOSE_PORTAL, NEW_CHOOSE_PORTAL) + +text = text.replace( + """ const DNS_ACTION_KEYS = { + choosePortalDns: false, + chooseExternalDns: false,""", + """ const DNS_ACTION_KEYS = { + choosePortalDns: false, + chooseByoDns: false, + chooseExternalDns: false,""", +) + +text = text.replace( + " if (dnsChoice === 'portal') markActionDone('choosePortalDns')\n if (dnsChoice === 'external') markActionDone('chooseExternalDns')", + " if (dnsChoice === 'portal') markActionDone('choosePortalDns')\n if (dnsChoice === 'byo') markActionDone('chooseByoDns')\n if (dnsChoice === 'external') markActionDone('chooseExternalDns')", +) + +text = text.replace( + " const data = await api(`/onboarding/dns/instructions/${dom}`)\n setInstructions(data)\n setCfStatus(data.cloudflare)", + " const data = await api(`/onboarding/dns/instructions/${dom}`)\n setInstructions(data)\n setCfStatus(data.cloudflare)\n setDnsResolve(data.resolve || null)", +) + +text = text.replace( + " if (step !== 1 || !instructions || showAdvancedDns || dnsChoice === 'external' || asmMode) return", + " if (step !== 1 || !instructions || showAdvancedDns || dnsChoice === 'external' || dnsChoice === 'byo' || asmMode) return", +) + +text = text.replace( + " if (portalDnsApplied || loading || autoDnsAttempted.current) return\n autoDnsAttempted.current = true", + " if (portalDnsApplied || loading || autoDnsAttempted.current) return\n const resolve = instructions?.resolve || dnsResolve\n if (resolve && !resolve.matched) return\n autoDnsAttempted.current = true", +) + +text = text.replace( + " dns_mode: dnsChoice === 'portal' ? 'Cloudflare Ligbox' : 'Provedor externo',", + " dns_mode:\n dnsChoice === 'portal'\n ? dnsResolve?.dns_mode || cfStatus?.dns_mode || 'ligbox_cf'\n : dnsChoice === 'byo'\n ? 'byo_cloudflare'\n : 'external_registrar',", +) + +# Advanced mode choice grid — add BYO button and update portal label +OLD_GRID = """ + """ + +NEW_GRID = """ + + """ + +text = text.replace(OLD_GRID, NEW_GRID) + +BYO_PANEL = """ + {dnsChoice === 'byo' && showAdvancedDns && ( +
+ Sua conta Cloudflare (BYO) +

+ Crie um API Token com permissão Zone DNS Edit apenas na zona{' '} + {domain}. +

+ +
+ +
+
+ )} + +""" + +if "dnsChoice === 'byo'" not in text: + text = text.replace( + " {dnsChoice === 'external' && showAdvancedDns && (", + BYO_PANEL + " {dnsChoice === 'external' && showAdvancedDns && (", + ) + +# Simple mode message when not matched +SIMPLE_MSG = """ + {!dnsResolve?.matched && !portalDnsApplied && ( + +

+ Este domínio não está nas contas Cloudflare Ligbox. Use{' '} + Minha conta Cloudflare ou{' '} + Registrador / DNS externo (botão técnico abaixo). +

+
+ )} + +""" + +if "não está nas contas Cloudflare Ligbox" not in text: + text = text.replace( + " {portalDnsApplied && (", + SIMPLE_MSG + " {portalDnsApplied && (", + ) + +APP.write_text(text, encoding="utf-8") +print("App.jsx patched OK") diff --git a/deploy/vm112-wizard/frontend-provision037b.patch.py b/deploy/vm112-wizard/frontend-provision037b.patch.py new file mode 100644 index 0000000..8513710 --- /dev/null +++ b/deploy/vm112-wizard/frontend-provision037b.patch.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Frontend Spec 037b — provision-zone cria zona cliente na CF Ligbox.""" +from pathlib import Path + +APP = Path("/opt/ligbox-wizard/frontend/src/App.jsx") +text = APP.read_text(encoding="utf-8") + +OLD = """ async function choosePortalDns() { + setDnsChoice('portal') + setError(null) + startBusy('dns_zone') + try { + const resolved = dnsResolve || (await api(`/onboarding/dns/resolve/${domain}`)) + setDnsResolve(resolved) + if (!resolved.matched) { + throw new Error( + resolved.message || + 'Domínio não está nas contas Ligbox (ligit, itecnologys, ibytera).', + ) + } + const cf = await refreshCfStatus() + setCfStatus(cf) + markActionDone('choosePortalDns') + markActionDone('verifyZone') + const data = await api('/onboarding/dns/cloudflare/apply', { + method: 'POST', + body: JSON.stringify({ + domain, + zone_id: resolved.zone_id, + mail_aliases: sanitizeMailAliases(mailAliasInputs, domain), + }), + }) + setPortalGuide({ + message: resolved.message, + nameservers: cf?.nameservers || [], + status: cf, + ligbox_account_id: resolved.account_id, + }) + setVerification(data.verification) + setResult({ type: 'dns', data }) + markActionDone('applyPortalDns') + } catch (e) { + setError(e.message) + setDnsChoice(null) + setActionsDone((prev) => ({ ...prev, choosePortalDns: false })) + } finally { + stopBusy() + } + }""" + +NEW = """ async function choosePortalDns() { + setDnsChoice('portal') + setError(null) + startBusy('dns_zone') + try { + const resolved = dnsResolve || (await api(`/onboarding/dns/resolve/${domain}`)) + setDnsResolve(resolved) + const guide = await api('/onboarding/dns/cloudflare/provision-zone', { + method: 'POST', + body: JSON.stringify({ domain }), + }) + setPortalGuide(guide) + const cf = guide.status || (await refreshCfStatus()) + setCfStatus(cf) + markActionDone('choosePortalDns') + if (guide.verification) { + setVerification(guide.verification) + setResult({ type: 'dns', data: { verification: guide.verification } }) + markActionDone('verifyZone') + markActionDone('applyPortalDns') + } else if (cf?.zone_in_account && cf?.can_apply_mail_records) { + markActionDone('verifyZone') + const data = await api('/onboarding/dns/cloudflare/apply', { + method: 'POST', + body: JSON.stringify({ + domain, + zone_id: guide.zone_id || resolved.zone_id, + mail_aliases: sanitizeMailAliases(mailAliasInputs, domain), + }), + }) + setVerification(data.verification) + setResult({ type: 'dns', data }) + markActionDone('applyPortalDns') + } + } catch (e) { + setError(e.message) + setDnsChoice(null) + setActionsDone((prev) => ({ ...prev, choosePortalDns: false })) + } finally { + stopBusy() + } + }""" + +if "provision-zone" in text and "if (!resolved.matched)" in text: + text = text.replace(OLD, NEW) + print("choosePortalDns restored with provision-zone") + +# Auto mode: allow for new domains too +text = text.replace( + " const resolve = instructions?.resolve || dnsResolve\n if (resolve && !resolve.matched) return\n autoDnsAttempted.current = true", + " autoDnsAttempted.current = true", +) + +# Ligbox button always enabled when can provision +text = text.replace( + """ label={ + dnsResolve?.matched + ? `Cloudflare Ligbox (${dnsResolve.account_id})` + : 'Cloudflare Ligbox (indisponível)' + } + hint={ + dnsResolve?.matched + ? dnsResolve.message + : 'Domínio não está nas contas ligit / itecnologys / ibytera.' + } + disabled={ + loading || + actionsDone.chooseExternalDns || + actionsDone.chooseByoDns || + !dnsResolve?.matched + }""", + """ label={ + dnsResolve?.matched + ? `Cloudflare Ligbox (${dnsResolve.account_id})` + : `Cloudflare Ligbox (${dnsResolve?.provision_account_id || 'nova zona'})` + } + hint={ + dnsResolve?.matched + ? dnsResolve.message + : 'Criar zona do seu domínio na Cloudflare gerenciada pela Ligbox.' + } + disabled={ + loading || + actionsDone.chooseExternalDns || + actionsDone.chooseByoDns || + dnsResolve?.can_provision_ligbox === false + }""", +) + +# Simple mode warning +text = text.replace( + """ {!dnsResolve?.matched && !portalDnsApplied && ( + +

+ Este domínio não está nas contas Cloudflare Ligbox. Use{' '} + Minha conta Cloudflare ou{' '} + Registrador / DNS externo (botão técnico abaixo). +

+
+ )}""", + """ {!dnsResolve?.matched && !portalDnsApplied && ( + +

+ Vamos criar a zona do seu domínio na Cloudflare gerenciada pela Ligbox + {dnsResolve?.provision_account_id + ? ` (conta ${dnsResolve.provision_account_id})` + : ''} + . Depois altere os nameservers no registrador. +

+
+ )}""", +) + +APP.write_text(text, encoding="utf-8") +print("App.jsx 037b patched OK") diff --git a/deploy/vm112-wizard/onboarding-dns-viewer-v4.patch.py b/deploy/vm112-wizard/onboarding-dns-viewer-v4.patch.py new file mode 100644 index 0000000..a44d7e6 --- /dev/null +++ b/deploy/vm112-wizard/onboarding-dns-viewer-v4.patch.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Spec 037 V4 — GET /onboarding/dns/viewer/{domain} na VM112. Idempotente.""" +from __future__ import annotations + +import os +from pathlib import Path + +ROUTER = Path("/opt/ligbox-wizard/backend/app/routers/onboarding.py") +text = ROUTER.read_text(encoding="utf-8") + +VIEWER_ENDPOINT = ''' + +@router.get("/dns/viewer/{domain}") +async def dns_viewer_onboarding(domain: str, request: Request): + """Painel DNS read-only — proxy Desk (Spec 037 V4). Sem edit_links para cliente.""" + get_session_from_request(request) + domain = normalize_domain(domain) + try: + validate_primary_domain(domain) + except ValueError as e: + raise HTTPException(status_code=400, detail=invalid_domain_http_detail()) from e + + import httpx + + desk = os.getenv("DESK_API_URL", "http://10.10.10.122:8080").rstrip("/") + token = os.getenv("OPS_INTERNAL_TOKEN", "") + headers = {"Accept": "application/json"} + if token: + headers["X-Ops-Internal-Token"] = token + url = f"{desk}/api/v1/dns/viewer/{domain}?email_service=true&include_public=true" + try: + async with httpx.AsyncClient(timeout=15.0) as client: + res = await client.get(url, headers=headers) + if res.status_code >= 400: + raise HTTPException(res.status_code, res.text[:500]) + payload = res.json() + except HTTPException: + raise + except Exception as e: + raise HTTPException(502, f"Desk DNS viewer indisponível: {e}") from e + + payload["edit_links"] = [] + payload["viewer_context"] = "wizard_onboarding" + return payload + +''' + +if "/dns/viewer/" not in text: + anchor = '@router.get("/dns/resolve/{domain}")' + if anchor not in text: + anchor = '@router.get("/dns/instructions/{domain}")' + text = text.replace(anchor, VIEWER_ENDPOINT + anchor) + ROUTER.write_text(text, encoding="utf-8") + print("onboarding.py: dns/viewer endpoint added") +else: + print("onboarding.py: dns/viewer already present") diff --git a/deploy/vm112-wizard/onboarding-dns037.patch.py b/deploy/vm112-wizard/onboarding-dns037.patch.py new file mode 100644 index 0000000..0990cd3 --- /dev/null +++ b/deploy/vm112-wizard/onboarding-dns037.patch.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Aplica patch Spec 037 em onboarding.py na VM112. Idempotente.""" +from pathlib import Path + +ROUTER = Path("/opt/ligbox-wizard/backend/app/routers/onboarding.py") +text = ROUTER.read_text(encoding="utf-8") + +IMPORT_OLD = "from app.services.cloudflare import CloudflareDNS, CloudflareError, is_cloudflare_sandbox, mail_dns_records, wizard_nameservers" +IMPORT_NEW = """from app.services.cloudflare import CloudflareDNS, CloudflareError, is_cloudflare_sandbox, mail_dns_records, wizard_nameservers +from app.services.dns_account_registry import resolve_ligbox_zone, resolve_payload +from app.services import dns_byo_vault""" + +if "dns_account_registry" not in text: + text = text.replace(IMPORT_OLD, IMPORT_NEW) + +MODEL_ANCHOR = "class CloudflareApplyRequest(BaseModel):" +MODEL_INSERT = """class CloudflareConnectCustomRequest(BaseModel): + domain: str + api_token: str = Field(..., min_length=20, max_length=512) + + +""" +if "CloudflareConnectCustomRequest" not in text: + text = text.replace(MODEL_ANCHOR, MODEL_INSERT + MODEL_ANCHOR) + +HELPER = ''' + +def _cf_client_for_domain(domain: str, session_id: str | None = None): + """Resolve cliente CF: conta Ligbox, BYO sessão, ou token legacy.""" + match = resolve_ligbox_zone(domain) + if match: + return match.account.client(), match.zone_id, match.dns_mode, match.account.id + if session_id: + byo = dns_byo_vault.load_byo_token(session_id, domain) + if byo: + return CloudflareDNS(token=byo.api_token), byo.zone_id, "byo_cloudflare", None + if settings.cloudflare_api_token: + cf = CloudflareDNS() + zone = cf.get_zone_by_name(domain) + if zone: + return cf, zone["id"], "ligbox_cf_legacy", None + return None, None, None, None + + +''' + +if "_cf_client_for_domain" not in text: + anchor = "def _cloudflare_zone_status(domain: str) -> dict:" + text = text.replace(anchor, HELPER + anchor) + +# Replace _cloudflare_zone_status body start +OLD_STATUS = '''def _cloudflare_zone_status(domain: str) -> dict: + domain = domain.lower().strip() + if not settings.cloudflare_api_token: + return { + "domain": domain, + "cloudflare_configured": False, + "zone_in_account": False, + "can_apply_mail_records": False, + "zone_status": None, + "nameservers": [], + "managed_zones": [], + "portal_onboarding_required": True, + "message": "API Cloudflare não configurada no servidor.", + } + cf = CloudflareDNS() + cf.verify_token() + managed = cf.list_zone_names() + zone = cf.get_zone_by_name(domain) + in_account = zone is not None + zone_status = zone.get("status") if zone else None + nameservers = wizard_nameservers(zone) if zone else [] + + return { + "domain": domain, + "cloudflare_configured": True, + "zone_in_account": in_account, + "can_apply_mail_records": in_account, + "zone_id": zone["id"] if zone else None, + "zone_status": zone_status, + "nameservers": nameservers, + "managed_zones": managed, + "portal_onboarding_required": not in_account, + "message": ( + f"Zona {domain} pronta na Cloudflare Ligbox ({zone_status})." + if in_account + else f"Adicione {domain} na Cloudflare Ligbox e altere os NS no registrador." + ), + }''' + +NEW_STATUS = '''def _cloudflare_zone_status(domain: str) -> dict: + domain = domain.lower().strip() + resolved = resolve_payload(domain) + match = resolve_ligbox_zone(domain) + if match: + cf = match.account.client() + try: + cf.verify_token() + except CloudflareError: + pass + zone = match.zone + zone_status = zone.get("status") + nameservers = wizard_nameservers(zone) + return { + "domain": domain, + "cloudflare_configured": True, + "zone_in_account": True, + "can_apply_mail_records": True, + "zone_id": match.zone_id, + "zone_status": zone_status, + "nameservers": nameservers, + "ligbox_account_id": match.account.id, + "ligbox_account_label": f"{match.account.label} ({match.account.admin_email})", + "dns_mode": match.dns_mode, + "resolve": resolved, + "managed_zones": [], + "portal_onboarding_required": zone_status != "active", + "message": ( + f"Zona {domain} na conta {match.account.label} ({zone_status})." + ), + } + legacy_ok = bool(settings.cloudflare_api_token) + return { + "domain": domain, + "cloudflare_configured": legacy_ok, + "zone_in_account": False, + "can_apply_mail_records": False, + "zone_id": None, + "zone_status": None, + "nameservers": [], + "ligbox_account_id": None, + "dns_mode": None, + "resolve": resolved, + "managed_zones": [], + "portal_onboarding_required": True, + "paths_available": resolved.get("paths_available") or ["byo", "external"], + "message": resolved.get("message") + or "Domínio fora das contas Ligbox — use BYO Cloudflare ou registrador.", + }''' + +if "ligbox_account_id" not in text: + text = text.replace(OLD_STATUS, NEW_STATUS) + +RESOLVE_ENDPOINT = ''' +@router.get("/dns/resolve/{domain}") +def dns_resolve_account(domain: str): + """Spec 037 — em qual conta Ligbox (ligit/itecnologys/ibytera) está a zona.""" + domain = domain.lower().strip().rstrip(".") + try: + activity_log.info(f"Resolver conta CF Ligbox: {domain}", source="cloudflare") + return resolve_payload(domain) + except CloudflareError as e: + activity_log.error(str(e), source="cloudflare") + raise HTTPException(502, f"Erro Cloudflare: {e}") from e + + +@router.post("/dns/cloudflare/connect-custom") +def connect_custom_cloudflare(body: CloudflareConnectCustomRequest, request: Request): + """BYO — token API Cloudflare do cliente (zona já na conta dele).""" + sid = 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 + cf = CloudflareDNS(token=body.api_token.strip()) + try: + cf.verify_token() + zone = cf.get_zone_by_name(domain) + if not zone: + raise HTTPException( + 422, + detail={ + "code": "zone_not_in_customer_account", + "domain": domain, + "message": f"Zona {domain} não encontrada com este API Token.", + }, + ) + if sid: + dns_byo_vault.save_byo_token(sid, domain, body.api_token.strip(), str(zone["id"])) + activity_log.ok(f"BYO Cloudflare ligado: {domain}", source="cloudflare") + return { + "domain": domain, + "matched": True, + "dns_mode": "byo_cloudflare", + "zone_id": zone.get("id"), + "zone_status": zone.get("status"), + "nameservers": wizard_nameservers(zone), + "message": "Conta Cloudflare do cliente validada. Pode aplicar apontamentos.", + } + except CloudflareError as e: + activity_log.error(f"BYO Cloudflare falhou: {e}", source="cloudflare") + raise HTTPException(401, f"Token inválido: {e}") from e + + +''' + +if "/dns/resolve/" not in text: + text = text.replace( + '@router.get("/dns/cloudflare/status/{domain}")', + RESOLVE_ENDPOINT + '@router.get("/dns/cloudflare/status/{domain}")', + ) + +# Fix indentation typo in patch if any - the RESOLVE has wrong indent on cf line +text = text.replace(" cf = CloudflareDNS(token=body.api_token.strip())", " cf = CloudflareDNS(token=body.api_token.strip())") + +# provision-zone guard +OLD_PROVISION_START = ''' cf = CloudflareDNS() + try: + activity_log.info(f"Criar zona Cloudflare: {domain}", source="cloudflare") + cf.verify_token() + result = cf.ensure_zone(domain)''' + +NEW_PROVISION_START = ''' match = resolve_ligbox_zone(domain) + if match: + activity_log.info( + f"Zona {domain} já na conta {match.account.id} — sem create", + source="cloudflare", + ) + payload = _portal_onboarding_payload(domain, zone_created=False) + payload["zone_id"] = match.zone_id + payload["ligbox_account_id"] = match.account.id + payload["dns_mode"] = match.dns_mode + payload["message"] = ( + f"Domínio {domain} já está na Cloudflare Ligbox ({match.account.label})." + ) + if nameservers := wizard_nameservers(match.zone): + payload["nameservers"] = nameservers + payload["status"]["nameservers"] = nameservers + return payload + + raise HTTPException( + status_code=422, + detail={ + "code": "zone_not_in_ligbox_accounts", + "domain": domain, + "message": ( + "Domínio não está nas contas Ligbox (ligit, itecnologys, ibytera). " + "Use API Token da sua Cloudflare ou apontamentos no registrador." + ), + "paths_available": ["byo", "external"], + }, + ) + + cf = CloudflareDNS() + try: + activity_log.info(f"Criar zona Cloudflare: {domain}", source="cloudflare") + cf.verify_token() + result = cf.ensure_zone(domain)''' + +if "zone_not_in_ligbox_accounts" not in text: + text = text.replace(OLD_PROVISION_START, NEW_PROVISION_START) + +OLD_APPLY = ''' if not settings.cloudflare_api_token: + raise HTTPException(400, _cloudflare_token_missing_error()) + + cf = CloudflareDNS() + try: + activity_log.info(f"Aplicar registos mail na Cloudflare: {domain}", source="cloudflare") + cf.verify_token() + except CloudflareError as e: + activity_log.error(str(e), source="cloudflare") + raise HTTPException(401, f"Token Cloudflare inválido: {e}") from e + + zone_id = body.zone_id + if not zone_id: + zone = cf.get_zone_by_name(domain) + if not zone: + activity_log.error(f"Zona {domain} não está na conta Ligbox", source="cloudflare") + raise HTTPException( + status_code=422, + detail={ + "code": "zone_not_in_account", + "domain": domain, + "message": f"Zona {domain} ainda não está na Cloudflare Ligbox.", + "use_portal_onboarding": True, + }, + ) + zone_id = zone["id"]''' + +NEW_APPLY = ''' sid = get_session_from_request(request) + cf, zone_id, dns_mode, ligbox_acct = _cf_client_for_domain(domain, sid) + if not cf or not zone_id: + activity_log.error(f"Sem conta CF para {domain}", source="cloudflare") + raise HTTPException( + status_code=422, + detail={ + "code": "zone_not_in_account", + "domain": domain, + "message": ( + f"Zona {domain} não está nas contas Ligbox nem BYO. " + "Ligue BYO ou use registrador externo." + ), + "paths_available": ["byo", "external"], + }, + ) + try: + activity_log.info( + f"Aplicar registos mail ({dns_mode or 'cf'}): {domain}", + source="cloudflare", + ) + cf.verify_token() + except CloudflareError as e: + activity_log.error(str(e), source="cloudflare") + raise HTTPException(401, f"Token Cloudflare inválido: {e}") from e + + if body.zone_id: + zone_id = body.zone_id''' + +if "ligbox_acct" not in text: + text = text.replace(OLD_APPLY, NEW_APPLY) + +# apply return add dns_mode +OLD_APPLY_RET = ' return {"domain": domain, "zone_id": zone_id, "applied": applied, "verification": verification}' +NEW_APPLY_RET = ''' return { + "domain": domain, + "zone_id": zone_id, + "dns_mode": dns_mode, + "ligbox_account_id": ligbox_acct, + "applied": applied, + "verification": verification, + }''' +if '"dns_mode": dns_mode' not in text: + text = text.replace(OLD_APPLY_RET, NEW_APPLY_RET) + +# dns instructions paths +OLD_PATHS = ''' "dns_paths": { + "portal": "Trazer DNS para o portal (Cloudflare conta Ligbox)", + "external": "Manter DNS no provedor atual (apontamentos manuais)", + },''' +NEW_PATHS = ''' "dns_paths": { + "ligbox": "Cloudflare Ligbox (ligit / itecnologys / ibytera) — apontamentos automáticos", + "byo": "Sua conta Cloudflare (API Token)", + "external": "Registrador / DNS externo (apontamentos manuais)", + }, + "resolve": resolve_payload(domain),''' +if '"byo"' not in text.split("dns_paths")[1][:200]: + text = text.replace(OLD_PATHS, NEW_PATHS) + +ROUTER.write_text(text, encoding="utf-8") +print("onboarding.py patched OK") diff --git a/deploy/vm112-wizard/patch-wizard-project-ui.py b/deploy/vm112-wizard/patch-wizard-project-ui.py new file mode 100644 index 0000000..0853641 --- /dev/null +++ b/deploy/vm112-wizard/patch-wizard-project-ui.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Wizard UI — provision-email + painel credenciais proj_*.""" +from pathlib import Path +import re + +APP = Path("/opt/ligbox-wizard/frontend/src/App.jsx") +text = APP.read_text(encoding="utf-8") + +if "projectCredentials" not in text: + text = text.replace( + " const [byoToken, setByoToken] = useState('')", + " const [byoToken, setByoToken] = useState('')\n" + " const [projectCredentials, setProjectCredentials] = useState(null)", + ) + +PROVISION_FN = ''' + async function ensureProjectEmail() { + if (projectCredentials?.ligbox_project_email) return projectCredentials + const data = await api('/onboarding/project/provision-email', { + method: 'POST', + body: JSON.stringify({ domain, display_name: companyLabelFromDomain(domain) }), + }) + setProjectCredentials(data) + return data + } + +''' + +if "ensureProjectEmail" not in text: + text = text.replace(" async function choosePortalDns() {", PROVISION_FN + " async function choosePortalDns() {") + +# inject ensureProjectEmail at start of choosePortalDns +if "await ensureProjectEmail()" not in text: + text = text.replace( + " startBusy('dns_zone')\n try {\n const resolved = dnsResolve", + " startBusy('dns_zone')\n try {\n const proj = await ensureProjectEmail()\n const resolved = dnsResolve", + ) + +PANEL = ''' + {projectCredentials?.ligbox_project_email && ( + +

+ E-mail: {projectCredentials.ligbox_project_email} +
+ Webmail: mail.ligbox.com.br +

+ {projectCredentials.password_available && !projectCredentials.password && ( + { + try { + const rev = await api(`/onboarding/project/${domain}/reveal-password`, { method: 'POST' }) + setProjectCredentials((p) => ({ ...p, password: rev.password, password_shown: true })) + } catch (e) { + setError(e.message) + } + }} + /> + )} + {projectCredentials.password && ( +

+ Senha: {projectCredentials.password} — guarde agora. +

+ )} +
+ )} + +''' + +if "Credenciais do projeto Ligbox" not in text: + text = text.replace( + " {portalDnsApplied && (", + PANEL + " {portalDnsApplied && (", + ) + +APP.write_text(text, encoding="utf-8") +print("App.jsx project panel OK") diff --git a/deploy/vm112-wizard/provision-zone037b.patch.py b/deploy/vm112-wizard/provision-zone037b.patch.py new file mode 100644 index 0000000..b506f9b --- /dev/null +++ b/deploy/vm112-wizard/provision-zone037b.patch.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Spec 037b — provision-zone cria zona do cliente na CF Ligbox.""" +from pathlib import Path + +ROUTER = Path("/opt/ligbox-wizard/backend/app/routers/onboarding.py") +REGISTRY = Path("/opt/ligbox-wizard/backend/app/services/dns_account_registry.py") +CF = Path("/opt/ligbox-wizard/backend/app/services/cloudflare.py") + +# --- cloudflare ensure_zone account_id --- +cf_text = CF.read_text(encoding="utf-8") +OLD_ENSURE = """ def ensure_zone(self, domain: str) -> dict: + \"\"\"Garante que a zona existe na conta Ibytera; cria se necessário.\"\"\" + zone = self.get_zone_by_name(domain) + if zone: + return {"zone": zone, "created": False} + zone = self.create_zone(domain) + return {"zone": zone, "created": True}""" + +NEW_ENSURE = """ def ensure_zone(self, domain: str, account_id: str | None = None) -> dict: + \"\"\"Garante que a zona existe na conta CF; cria se necessário.\"\"\" + zone = self.get_zone_by_name(domain) + if zone: + return {"zone": zone, "created": False} + zone = self.create_zone(domain, account_id=account_id) + return {"zone": zone, "created": True}""" + +if "account_id: str | None = None" not in cf_text: + cf_text = cf_text.replace(OLD_ENSURE, NEW_ENSURE) + CF.write_text(cf_text, encoding="utf-8") + print("cloudflare.py patched") + +# --- registry: ensure pick_provision_account exists (copy from monorepo if needed) --- +# handled via scp separately + +# --- onboarding import --- +text = ROUTER.read_text(encoding="utf-8") +if "provision_ligbox_zone" not in text: + text = text.replace( + "from app.services.dns_account_registry import resolve_ligbox_zone, resolve_payload", + "from app.services.dns_account_registry import (\n pick_provision_account,\n provision_ligbox_zone,\n resolve_ligbox_zone,\n resolve_payload,\n)", + ) + +OLD_BLOCK = ''' match = resolve_ligbox_zone(domain) + if match: + activity_log.info( + f"Zona {domain} já na conta {match.account.id} — sem create", + source="cloudflare", + ) + payload = _portal_onboarding_payload(domain, zone_created=False) + payload["zone_id"] = match.zone_id + payload["ligbox_account_id"] = match.account.id + payload["dns_mode"] = match.dns_mode + payload["message"] = ( + f"Domínio {domain} já está na Cloudflare Ligbox ({match.account.label})." + ) + if nameservers := wizard_nameservers(match.zone): + payload["nameservers"] = nameservers + payload["status"]["nameservers"] = nameservers + return payload + + raise HTTPException( + status_code=422, + detail={ + "code": "zone_not_in_ligbox_accounts", + "domain": domain, + "message": ( + "Domínio não está nas contas Ligbox (ligit, itecnologys, ibytera). " + "Use API Token da sua Cloudflare ou apontamentos no registrador." + ), + "paths_available": ["byo", "external"], + }, + ) + + cf = CloudflareDNS() + try: + activity_log.info(f"Criar zona Cloudflare: {domain}", source="cloudflare") + cf.verify_token() + result = cf.ensure_zone(domain)''' + +NEW_BLOCK = ''' provision_acct = pick_provision_account() + if not provision_acct: + raise HTTPException( + 400, + "Nenhuma conta Cloudflare Ligbox configurada. Ver dns-accounts.yaml e secrets/.", + ) + + try: + activity_log.info( + f"Provision zona cliente {domain} (conta Ligbox {provision_acct.id})", + source="cloudflare", + ) + provision_acct.client().verify_token() + result = provision_ligbox_zone(domain, account=provision_acct) + match = result["account"] + cf = match.client() + result = {"zone": result["zone"], "created": result["created"]}''' + +if "provision_ligbox_zone(domain" not in text: + text = text.replace(OLD_BLOCK, NEW_BLOCK) + # fix variable shadowing - after replace, need to fix zone/created extraction + text = text.replace( + " match = result["account"]\n cf = match.client()\n result = {"zone": result["zone"], "created": result["created"]}", + " ligbox_acct = result["account"]\n cf = ligbox_acct.client()\n zone = result["zone"]\n created = result["created"]", + ) + # Remove duplicate zone = result["zone"] if the old code still has it - read and fix + text = text.replace( + " zone = result[\"zone\"]\n created = result[\"created\"]\n zone = result[\"zone\"]\n created = result[\"created\"]", + " zone = result[\"zone\"]\n created = result[\"created\"]", + ) + +# Add ligbox_account_id to payload after zone created +if 'payload["ligbox_account_id"]' not in text.split("provision_cloudflare_zone")[1][:2500]: + text = text.replace( + ' payload["zone_id"] = zone.get("id")\n payload["sandbox"] = sandbox', + ' payload["zone_id"] = zone.get("id")\n payload["ligbox_account_id"] = ligbox_acct.id\n payload["dns_mode"] = f"ligbox_cf_{ligbox_acct.id}"\n payload["sandbox"] = sandbox', + ) + +ROUTER.write_text(text, encoding="utf-8") +print("onboarding.py provision-zone patched OK") diff --git a/projects/ops-desk/api/app/dns_viewer.py b/projects/ops-desk/api/app/dns_viewer.py new file mode 100644 index 0000000..a81251c --- /dev/null +++ b/projects/ops-desk/api/app/dns_viewer.py @@ -0,0 +1,345 @@ +"""Unified DNS viewer (read-only) — Spec 037-DNS-VIEWER.""" + +from __future__ import annotations + +import os +import re +from typing import Any + +import httpx + +from app.cloudflare_dns import fetch_domain_dns +from app.collectors.dns import _dig, collect +from app.openpanel_dns import ( + fetch_openpanel_bind_records, + ns_points_to_openpanel, +) + +VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090") + +CF_NS_SUFFIX = ".ns.cloudflare.com" + +MODE_LABELS: dict[str, str] = { + "ligbox_cf_ligit": "Cloudflare Ligbox (ligit)", + "ligbox_cf_itecnologys": "Cloudflare Ligbox (itecnologys)", + "ligbox_cf_ibytera": "Cloudflare Ligbox (ibytera)", + "ligbox_cf": "Cloudflare Ligbox", + "ligbox_cf_provision_pending": "DNS Ligbox (aguarda NS)", + "byo_cf": "Cloudflare cliente (BYO)", + "external": "DNS externo / registrador", + "registrar": "DNS externo / registrador", + "openpanel_bind": "OpenPanel BIND", + "unknown": "A determinar", +} + + +def _ns_list(domain: str) -> list[str]: + lines = _dig(domain, "NS") + return [re.sub(r"\.$", "", ln.lower()) for ln in lines if ln.strip()] + + +def _ligbox_cf_ns() -> list[str]: + return [] + + +def _ns_match_cloudflare(ns: list[str]) -> bool: + return any(n.endswith(CF_NS_SUFFIX) for n in ns) + + +def _public_checks(domain: str) -> dict[str, Any]: + raw = collect(domain) + out: dict[str, Any] = {} + for key, check_id in ( + ("mx", "dns_mx"), + ("spf", "dns_spf"), + ("dkim", "dns_dkim"), + ("dmarc", "dns_dmarc"), + ): + item = raw.get(check_id, {}) + status = item.get("status", "fail") + out[key] = { + "ok": status == "pass", + "warn": status == "warn", + "values": (item.get("evidence") or {}).get("records") or [], + "hint": item.get("message") if status != "pass" else None, + } + return out + + +def _records_from_public(domain: str, checks: dict[str, Any]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + mx_vals = checks.get("mx", {}).get("values") or [] + for line in mx_vals[:5]: + m = re.match(r"(\d+)\s+(.+)", line.strip()) + if m: + rows.append( + { + "source": "public_resolver", + "status": "actual", + "type": "MX", + "name": domain, + "content": m.group(2).rstrip("."), + "priority": int(m.group(1)), + "ttl": None, + "purpose": "mx", + "email_related": True, + } + ) + for txt in (checks.get("spf", {}).get("values") or [])[:2]: + rows.append( + { + "source": "public_resolver", + "status": "actual", + "type": "TXT", + "name": domain, + "content": txt, + "priority": None, + "ttl": None, + "purpose": "spf", + "email_related": True, + } + ) + for txt in (checks.get("dkim", {}).get("values") or [])[:1]: + rows.append( + { + "source": "public_resolver", + "status": "actual", + "type": "TXT", + "name": f"default._domainkey.{domain}", + "content": txt[:200], + "priority": None, + "ttl": None, + "purpose": "dkim", + "email_related": True, + } + ) + for txt in (checks.get("dmarc", {}).get("values") or [])[:1]: + rows.append( + { + "source": "public_resolver", + "status": "actual", + "type": "TXT", + "name": f"_dmarc.{domain}", + "content": txt[:200], + "priority": None, + "ttl": None, + "purpose": "dmarc", + "email_related": True, + } + ) + a_mail = _dig(f"mail.{domain}", "A") + for ip in a_mail[:2]: + rows.append( + { + "source": "public_resolver", + "status": "actual", + "type": "A", + "name": f"mail.{domain}", + "content": ip, + "priority": None, + "ttl": None, + "purpose": "mail-host", + "email_related": True, + } + ) + return rows + + +async def _wizard_resolve(domain: str) -> dict[str, Any] | None: + try: + async with httpx.AsyncClient(timeout=6.0) as client: + res = await client.get(f"{VM112_API}/api/onboarding/dns/resolve/{domain}") + if res.status_code == 200: + data = res.json() + if isinstance(data, dict): + return data + except Exception: + pass + return None + + +def _infer_mode( + resolve: dict[str, Any] | None, + cf_zone: dict | None, + public_ns: list[str], + *, + openpanel_zone: bool = False, + openpanel_ns: bool = False, +) -> tuple[str, str, str]: + if openpanel_zone or openpanel_ns: + return "openpanel_bind", "applied", "openpanel_bind" + if resolve: + mode = str(resolve.get("dns_mode") or "unknown") + if mode.startswith("ligbox_cf"): + if cf_zone and public_ns and not _ns_match_cloudflare(public_ns): + return "ligbox_cf_provision_pending", "planned", "cf_ligbox" + return mode if mode != "unknown" else "ligbox_cf", "applied", "cf_ligbox" + if mode in ("byo_cf", "external", "registrar", "openpanel_bind"): + display = "actual" if mode != "byo_cf" else "applied" + src = "cf_byo" if mode == "byo_cf" else "public_resolver" + return mode, display, src + if cf_zone: + if public_ns and not _ns_match_cloudflare(public_ns): + return "ligbox_cf_provision_pending", "planned", "cf_ligbox" + return "ligbox_cf", "applied", "cf_ligbox" + if public_ns: + return "external", "actual", "public_resolver" + return "unknown", "actual", "public_resolver" + + +def _edit_links( + domain: str, + dns_mode: str, + zone: dict | None, + role: str, + authoritative: str = "", +) -> list[dict[str, Any]]: + from app.permissions import can_open_cloudflare_dns_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 + links.append( + { + "label": "Editar na Cloudflare (Ligbox)", + "provider": "cf_ligbox", + "url": f"https://dash.cloudflare.com/?to=/:account/{zone_name}/dns", + "roles": ["super_admin", "ops_lead", "devops", "seo"], + } + ) + if dns_mode in ("external", "registrar", "unknown"): + links.append( + { + "label": "Registro.br / registrador", + "provider": "registrar", + "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 + + links.append( + { + "label": "Editar no OpenPanel", + "provider": "openpanel_bind", + "url": f"{OPENPANEL_URL}/domains/{domain}/dns", + "roles": ["super_admin", "ops_lead", "sales_admin", "seo"], + } + ) + return links + + +def _mode_message(display_mode: str, dns_mode: str) -> str: + if dns_mode.startswith("ligbox_cf") and display_mode == "planned": + return ( + "Estes apontamentos serão configurados na Cloudflare Ligbox " + "após delegar os nameservers." + ) + if dns_mode.startswith("ligbox_cf"): + return "Apontamentos activos ou geridos na Cloudflare Ligbox." + if dns_mode in ("external", "registrar"): + return "O domínio usa DNS fora da Ligbox. Abaixo: resolução pública actual." + if dns_mode == "openpanel_bind": + return "Zona servida pelo DNS Ligbox (OpenPanel BIND)." + return "Origem DNS em análise — verifique nameservers e zona." + + +async def fetch_dns_viewer( + domain: str, + *, + role: str, + email_service: bool | None = None, + include_public: bool = True, +) -> dict[str, Any]: + domain = domain.lower().strip().rstrip(".") + errors: list[str] = [] + + resolve = await _wizard_resolve(domain) + cf_payload = await fetch_domain_dns(domain, email_service=email_service) + cf_zone = cf_payload.get("zone") + public_ns = _ns_list(domain) + op_bind = fetch_openpanel_bind_records(domain) + op_records = op_bind.get("records") or [] + op_zone = bool(op_bind.get("zone_responds") or op_records) + op_ns = bool(op_bind.get("ns_on_openpanel") or ns_points_to_openpanel(domain)) + + ligbox_ns = [] + if resolve and resolve.get("matched"): + ligbox_ns = resolve.get("nameservers") or [] + + dns_mode, display_mode, authoritative = _infer_mode( + resolve, cf_zone, public_ns, openpanel_zone=op_zone, openpanel_ns=op_ns + ) + public_checks = _public_checks(domain) if include_public else {} + + records: list[dict[str, Any]] = [] + planned_records: list[dict[str, Any]] = [] + + if authoritative == "openpanel_bind" and op_records: + records = list(op_records) + if op_bind.get("error") and op_bind["error"] not in errors: + errors.append(op_bind["error"]) + elif display_mode == "actual" and not cf_zone: + records = _records_from_public(domain, public_checks) + authoritative = "public_resolver" + elif cf_payload.get("records"): + for r in cf_payload["records"]: + records.append( + { + **r, + "source": "cf_ligbox", + "status": "applied" if display_mode == "applied" else "planned", + } + ) + elif display_mode == "actual": + records = _records_from_public(domain, public_checks) + + if not records and op_records and not cf_zone: + records = list(op_records) + dns_mode = "openpanel_bind" + authoritative = "openpanel_bind" + display_mode = "applied" + + if display_mode == "planned" and cf_payload.get("records"): + planned_records = [{**r, "source": "planned_ligbox", "status": "planned"} for r in cf_payload["records"]] + + 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 []) + + return { + "domain": domain, + "dns_mode": dns_mode, + "mode_label": MODE_LABELS.get(dns_mode, dns_mode), + "display_mode": display_mode, + "authoritative_source": authoritative, + "mode_message": _mode_message(display_mode, dns_mode), + "nameservers": { + "current_public": public_ns, + "ligbox_cloudflare": cf_ns_expected, + "match_ligbox": _ns_match_cloudflare(public_ns) if public_ns else False, + }, + "records": records, + "planned_records": planned_records, + "public_checks": public_checks, + "edit_links": _edit_links(domain, dns_mode, cf_zone, role, authoritative), + "instructions": None, + "resolve": resolve, + "openpanel": { + "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"), + }, + "zone": cf_zone, + "email_service": cf_payload.get("email_service"), + "summary": { + "total": len(records), + "planned": len(planned_records), + "email_related": sum(1 for r in records if r.get("email_related")), + }, + "errors": errors + ([cf_payload["error"]] if cf_payload.get("error") else []), + "legacy_cf": cf_payload, + } diff --git a/projects/ops-desk/api/app/domain_console_routes.py b/projects/ops-desk/api/app/domain_console_routes.py new file mode 100644 index 0000000..c11f65a --- /dev/null +++ b/projects/ops-desk/api/app/domain_console_routes.py @@ -0,0 +1,66 @@ +"""Domain admin API proxy for Ligbox Console — Spec 035 / 037 V3.""" + +from __future__ import annotations + +import os + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app import auth +from app.dns_viewer import fetch_dns_viewer +from app.openpanel_dns import fetch_openpanel_bind_records +from app.permissions import can_read_dns_viewer + +router = APIRouter(prefix="/api/v1/domain-console", tags=["domain-console"]) + +DNS_VIEWER_ENABLED = os.getenv("DNS_VIEWER_ENABLED", "1").lower() in ("1", "true", "yes") + +STAFF_ROLES = frozenset( + { + "super_admin", + "ops_lead", + "devops", + "seo", + "technician", + "developer", + "noc", + "sales_admin", + "sales_support", + } +) + + +@router.get("/dns/viewer/{domain}") +async def domain_console_dns_viewer( + domain: str, + email_service: bool | None = Query(default=True), + include_public: bool = Query(default=True), + user: auth.DeskUser = Depends(auth.get_current_user), +): + """DNS Viewer for Console /admin/dominio.""" + if not DNS_VIEWER_ENABLED: + raise HTTPException(404, "DNS Viewer disabled") + if not can_read_dns_viewer(user.role): + raise HTTPException(403, "insufficient permissions") + domain = domain.lower().strip().rstrip(".") + payload = await fetch_dns_viewer( + domain, + role=user.role, + email_service=email_service, + include_public=include_public, + ) + if user.role not in STAFF_ROLES: + payload["edit_links"] = [ + link for link in payload.get("edit_links", []) if link.get("provider") != "cf_ligbox" + ] + return payload + + +@router.get("/dns/openpanel/records") +async def domain_console_openpanel_records( + domain: str = Query(..., min_length=3), + user: auth.DeskUser = Depends(auth.get_current_user), +): + if not can_read_dns_viewer(user.role): + raise HTTPException(403, "insufficient permissions") + return fetch_openpanel_bind_records(domain.lower().strip().rstrip(".")) diff --git a/projects/ops-desk/api/app/domain_console_sandbox.py b/projects/ops-desk/api/app/domain_console_sandbox.py new file mode 100644 index 0000000..93be4af --- /dev/null +++ b/projects/ops-desk/api/app/domain_console_sandbox.py @@ -0,0 +1,316 @@ +"""Sandbox Área Gerente — produção create-only, nunca apaga existentes (Spec 035).""" + +from __future__ import annotations + +import os +import re +import secrets +import sqlite3 +import uuid +from typing import Any + +import httpx + +from app import domain_console_sandbox_store, vm112_domains + +VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090") +SANDBOX_DOMAIN_PREFIX = os.getenv("DOMAIN_CONSOLE_SANDBOX_PREFIX", "cenario-") +SANDBOX_ALLOWED_SUFFIX = os.getenv( + "DOMAIN_CONSOLE_SANDBOX_SUFFIX", + ".ops.ligbox.com.br", +) +SANDBOX_ENABLED = os.getenv("DOMAIN_CONSOLE_SANDBOX", "1") == "1" + +PROTECTED_DOMAINS = vm112_domains.PURGE_BLOCKLIST | frozenset( + {"ligbox.com.br", "itecnologys.com", "ligbox.com", "myvexx.com"} +) + +_SCENARIO_DOMAIN_RE = re.compile( + rf"^{re.escape(SANDBOX_DOMAIN_PREFIX)}[a-z0-9]([a-z0-9-]{{0,61}}[a-z0-9])?{re.escape(SANDBOX_ALLOWED_SUFFIX)}$", + re.I, +) + + +class SandboxError(ValueError): + pass + + +class SandboxForbidden(PermissionError): + pass + + +def assert_enabled() -> None: + if not SANDBOX_ENABLED: + raise SandboxForbidden("DOMAIN_CONSOLE_SANDBOX desactivado") + + +def assert_no_delete(operation: str) -> None: + raise SandboxForbidden( + f"Sandbox create-only: operação «{operation}» proibida — nada existente pode ser apagado" + ) + + +def is_scenario_domain(domain: str) -> bool: + return bool(_SCENARIO_DOMAIN_RE.match(domain.lower().strip())) + + +def suggest_scenario_domain(slug: str | None = None) -> str: + slug = (slug or uuid.uuid4().hex[:8]).lower() + slug = re.sub(r"[^a-z0-9-]", "-", slug).strip("-") or uuid.uuid4().hex[:8] + return f"{SANDBOX_DOMAIN_PREFIX}{slug}{SANDBOX_ALLOWED_SUFFIX}" + + +def _vm112_onboarding_headers(session_id: str) -> dict[str, str]: + return {"X-Onboarding-Session": session_id, "Content-Type": "application/json"} + + +def _extract_account_emails(domain_data: dict[str, Any]) -> list[str]: + emails: list[str] = [] + for key in ("accounts", "accounts_preview", "emails"): + raw = domain_data.get(key) + if isinstance(raw, list): + for item in raw: + if isinstance(item, str) and "@" in item: + emails.append(item.lower()) + elif isinstance(item, dict): + e = item.get("email") or item.get("name") + if e and "@" in str(e): + emails.append(str(e).lower()) + admin = domain_data.get("admin_email") or domain_data.get("portal_admin") + if admin and "@" in str(admin): + emails.append(str(admin).lower()) + return sorted(set(emails)) + + +def fetch_domain_readonly(domain: str) -> dict[str, Any]: + domain = domain.lower().strip() + try: + data = vm112_domains.get_domain(domain) + return {"ok": True, "source": "vm112", "domain": domain, "data": data} + except httpx.HTTPError as exc: + return { + "ok": False, + "source": "vm112", + "domain": domain, + "error": str(exc), + "data": None, + } + + +def list_domains_readonly(q: str = "") -> dict[str, Any]: + try: + data = vm112_domains.list_domains(q) + return {"ok": True, "source": "vm112", "data": data} + except httpx.HTTPError as exc: + return {"ok": False, "source": "vm112", "error": str(exc), "data": None} + + +def _create_account_vm112( + *, + domain: str, + local_part: str, + password: str, + display_name: str, +) -> dict[str, Any]: + session_id = f"sandbox-{uuid.uuid4().hex}" + body = { + "domain": domain, + "local_part": local_part, + "password": password, + "display_name": display_name, + "use_server_password": False, + "send_welcome": False, + "dns_mode": "sandbox", + } + with httpx.Client(timeout=180.0) as client: + r = client.post( + f"{VM112_API}/api/onboarding/account/create", + json=body, + headers=_vm112_onboarding_headers(session_id), + ) + if r.status_code >= 400: + try: + detail = r.json() + except Exception: + detail = r.text + raise SandboxError(f"VM112 account/create HTTP {r.status_code}: {detail}") + return r.json() + + +def create_scenario( + conn: sqlite3.Connection, + *, + label: str, + domain: str | None, + slug: str | None, + created_by: str, + plan_mock: str = "Business", + admin_password: str | None = None, +) -> dict[str, Any]: + assert_enabled() + domain = (domain or suggest_scenario_domain(slug)).lower().strip() + + if domain in PROTECTED_DOMAINS: + raise SandboxForbidden(f"Domínio protegido: {domain}") + + if not is_scenario_domain(domain): + raise SandboxForbidden( + f"Sandbox só cria domínios novos com prefixo «{SANDBOX_DOMAIN_PREFIX}» " + f"e sufixo «{SANDBOX_ALLOWED_SUFFIX}». Sugestão: {suggest_scenario_domain(slug)}" + ) + + existing = fetch_domain_readonly(domain) + baseline: list[str] = [] + if existing.get("ok") and existing.get("data"): + baseline = _extract_account_emails(existing["data"]) + if baseline: + raise SandboxForbidden( + f"Domínio {domain} já tem contas em produção — sandbox não altera existentes. " + "Use outro nome de cenário." + ) + + password = admin_password or secrets.token_urlsafe(12) + if len(password) < 8: + raise SandboxError("Senha admin mínimo 8 caracteres") + + vm112_result = _create_account_vm112( + domain=domain, + local_part="admin", + password=password, + display_name=f"Gerente {label or domain}", + ) + + scenario = domain_console_sandbox_store.create_scenario( + conn, + domain=domain, + label=label or domain, + baseline_accounts=baseline, + created_by=created_by, + plan_mock=plan_mock, + ) + + admin_email = f"admin@{domain}" + return { + "scenario": scenario, + "vm112": vm112_result, + "credentials": { + "admin_email": admin_email, + "password": password, + "webmail": f"https://mail.{domain}/", + "console": "https://onboard.ligbox.com.br/admin", + }, + "guards": { + "delete_blocked": True, + "baseline_protected": baseline, + "mode": "create-only", + }, + } + + +def add_account_to_scenario( + conn: sqlite3.Connection, + *, + scenario_id: str, + local_part: str, + display_name: str, + password: str | None = None, +) -> dict[str, Any]: + assert_enabled() + scenario = domain_console_sandbox_store.get_scenario(conn, scenario_id) + if not scenario: + raise SandboxError("Cenário não encontrado") + + domain = scenario["domain"] + local_part = re.sub(r"[^a-z0-9._+-]", "", local_part.lower().strip()) + if not local_part: + raise SandboxError("local_part inválido") + + email = f"{local_part}@{domain}" + baseline = set(scenario["baseline_accounts"]) + if email in baseline: + raise SandboxForbidden( + f"{email} já existia antes do cenário — sandbox não sobrescreve contas existentes" + ) + + if email in scenario["created_accounts"]: + raise SandboxError(f"{email} já foi criada neste cenário") + + pwd = password or secrets.token_urlsafe(10) + if len(pwd) < 8: + raise SandboxError("Senha mínimo 8 caracteres") + + vm112_result = _create_account_vm112( + domain=domain, + local_part=local_part, + password=pwd, + display_name=display_name or local_part, + ) + + updated = domain_console_sandbox_store.append_created_account(conn, scenario_id, email) + return { + "scenario": updated, + "account": { + "email": email, + "password": pwd, + "display_name": display_name or local_part, + }, + "vm112": vm112_result, + "guards": {"delete_blocked": True, "mode": "create-only"}, + } + + +def build_console_state(conn: sqlite3.Connection, scenario_id: str) -> dict[str, Any]: + """Estado UI — merge VM112 read + cenário local.""" + scenario = domain_console_sandbox_store.get_scenario(conn, scenario_id) + if not scenario: + raise SandboxError("Cenário não encontrado") + + domain = scenario["domain"] + live = fetch_domain_readonly(domain) + live_emails = _extract_account_emails(live["data"]) if live.get("data") else [] + + accounts = [] + seen = set() + for email in live_emails or scenario["created_accounts"]: + if email in seen: + continue + seen.add(email) + is_baseline = email in scenario["baseline_accounts"] + is_created = email in scenario["created_accounts"] + accounts.append( + { + "email": email, + "name": email.split("@")[0].title(), + "mail_gb": 30, + "files_gb": 200, + "nc": True, + "active": True, + "protected": is_baseline, + "sandbox_created": is_created, + "can_delete": False, + } + ) + + return { + "mode": "live-sandbox", + "sandbox": True, + "delete_blocked": True, + "scenario": scenario, + "domain": domain, + "plan": { + "name": scenario["plan_mock"], + "price": 549, + "maxSeats": 25, + "mailGbDefault": 30, + "filesGbDefault": 200, + }, + "billing": {"status": "Sandbox", "nextRenewal": "—"}, + "dmarc": {"spf": True, "dkim": True, "dmarc": True, "score": 95}, + "dns": { + "mx": live.get("ok", False), + "aMail": live.get("ok", False), + "subdomain": f"intranet.{domain}", + }, + "accounts": accounts, + "vm112_live": live, + } diff --git a/projects/ops-desk/api/app/domain_console_sandbox_routes.py b/projects/ops-desk/api/app/domain_console_sandbox_routes.py new file mode 100644 index 0000000..1db414b --- /dev/null +++ b/projects/ops-desk/api/app/domain_console_sandbox_routes.py @@ -0,0 +1,175 @@ +"""API sandbox — Área Gerente create-only (Spec 035).""" + +from __future__ import annotations + +import sqlite3 +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from app import auth, domain_console_sandbox as sandbox +from app.permissions import can_manage_vm112_domains + +router = APIRouter(prefix="/api/v1/domain-console/sandbox", tags=["domain-console-sandbox"]) + + +def _require_sandbox(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser: + if not can_manage_vm112_domains(user.role): + raise HTTPException(403, "Sem permissão sandbox domain console") + return user + + +def _db() -> sqlite3.Connection: + from app.main import db + + return db() + + +class CreateScenarioBody(BaseModel): + label: str = Field(..., min_length=2, max_length=120) + domain: str | None = None + slug: str | None = Field(None, max_length=40) + plan_mock: str = "Business" + admin_password: str | None = Field(None, min_length=8) + + +class AddAccountBody(BaseModel): + local_part: str = Field(..., min_length=1, max_length=64) + display_name: str = Field("", max_length=120) + password: str | None = Field(None, min_length=8) + + +@router.get("/config") +def sandbox_config(_user: auth.DeskUser = Depends(_require_sandbox)): + return { + "enabled": sandbox.SANDBOX_ENABLED, + "mode": "create-only", + "delete_blocked": True, + "domain_prefix": sandbox.SANDBOX_DOMAIN_PREFIX, + "domain_suffix": sandbox.SANDBOX_ALLOWED_SUFFIX, + "suggested_domain": sandbox.suggest_scenario_domain(), + "protected_domains": sorted(sandbox.PROTECTED_DOMAINS), + } + + +@router.get("/domains") +def list_domains_readonly( + q: str = "", + _user: auth.DeskUser = Depends(_require_sandbox), +): + return sandbox.list_domains_readonly(q) + + +@router.get("/domains/{domain}") +def get_domain_readonly( + domain: str, + _user: auth.DeskUser = Depends(_require_sandbox), +): + return sandbox.fetch_domain_readonly(domain) + + +@router.get("/scenarios") +def list_scenarios( + user: auth.DeskUser = Depends(_require_sandbox), +): + from app.domain_console_sandbox_store import init_schema, list_scenarios as ls + + conn = _db() + init_schema(conn) + return {"scenarios": ls(conn), "delete_blocked": True} + + +@router.post("/scenarios") +def create_scenario( + body: CreateScenarioBody, + user: auth.DeskUser = Depends(_require_sandbox), +): + from app.domain_console_sandbox_store import init_schema + + conn = _db() + init_schema(conn) + try: + return sandbox.create_scenario( + conn, + label=body.label, + domain=body.domain, + slug=body.slug, + created_by=user.username, + plan_mock=body.plan_mock, + admin_password=body.admin_password, + ) + except sandbox.SandboxForbidden as e: + raise HTTPException(403, str(e)) from e + except sandbox.SandboxError as e: + raise HTTPException(400, str(e)) from e + + +@router.get("/scenarios/{scenario_id}/state") +def scenario_state( + scenario_id: str, + _user: auth.DeskUser = Depends(_require_sandbox), +): + from app.domain_console_sandbox_store import init_schema + + conn = _db() + init_schema(conn) + try: + return sandbox.build_console_state(conn, scenario_id) + except sandbox.SandboxError as e: + raise HTTPException(404, str(e)) from e + + +@router.post("/scenarios/{scenario_id}/accounts") +def add_account( + scenario_id: str, + body: AddAccountBody, + _user: auth.DeskUser = Depends(_require_sandbox), +): + from app.domain_console_sandbox_store import init_schema + + conn = _db() + init_schema(conn) + try: + return sandbox.add_account_to_scenario( + conn, + scenario_id=scenario_id, + local_part=body.local_part, + display_name=body.display_name, + password=body.password, + ) + except sandbox.SandboxForbidden as e: + raise HTTPException(403, str(e)) from e + except sandbox.SandboxError as e: + raise HTTPException(400, str(e)) from e + + +@router.delete("/scenarios/{scenario_id}") +def block_delete_scenario(scenario_id: str, _user: auth.DeskUser = Depends(_require_sandbox)): + try: + sandbox.assert_no_delete(f"delete scenario {scenario_id}") + except sandbox.SandboxForbidden as e: + raise HTTPException(403, str(e)) from e + raise HTTPException(403, "delete bloqueado") + + +@router.delete("/scenarios/{scenario_id}/accounts/{email}") +def block_delete_account( + scenario_id: str, + email: str, + _user: auth.DeskUser = Depends(_require_sandbox), +): + try: + sandbox.assert_no_delete(f"delete account {email}") + except sandbox.SandboxForbidden as e: + raise HTTPException(403, str(e)) from e + raise HTTPException(403, "delete bloqueado") + + +@router.post("/scenarios/{scenario_id}/purge") +def block_purge_scenario(scenario_id: str, _user: auth.DeskUser = Depends(_require_sandbox)): + try: + sandbox.assert_no_delete(f"purge scenario {scenario_id}") + except sandbox.SandboxForbidden as e: + raise HTTPException(403, str(e)) from e + raise HTTPException(403, "purge bloqueado") diff --git a/projects/ops-desk/api/app/domain_console_sandbox_store.py b/projects/ops-desk/api/app/domain_console_sandbox_store.py new file mode 100644 index 0000000..e4ae72a --- /dev/null +++ b/projects/ops-desk/api/app/domain_console_sandbox_store.py @@ -0,0 +1,134 @@ +"""Persistência cenários sandbox — Spec 035 (create-only audit trail).""" + +from __future__ import annotations + +import json +import sqlite3 +import uuid +from datetime import datetime, timezone +from typing import Any + + +def _ts() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def init_schema(conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS domain_console_scenarios ( + id TEXT PRIMARY KEY, + domain TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + plan_mock TEXT NOT NULL DEFAULT 'Business', + baseline_accounts_json TEXT NOT NULL DEFAULT '[]', + created_accounts_json TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'active', + created_by TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_dcs_domain ON domain_console_scenarios(domain); + """ + ) + conn.commit() + + +def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]: + d = dict(row) + d["baseline_accounts"] = json.loads(d.pop("baseline_accounts_json") or "[]") + d["created_accounts"] = json.loads(d.pop("created_accounts_json") or "[]") + return d + + +def list_scenarios(conn: sqlite3.Connection, limit: int = 50) -> list[dict[str, Any]]: + rows = conn.execute( + """ + SELECT * FROM domain_console_scenarios + WHERE status = 'active' + ORDER BY created_at DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [_row_to_dict(r) for r in rows] + + +def get_scenario(conn: sqlite3.Connection, scenario_id: str) -> dict[str, Any] | None: + row = conn.execute( + "SELECT * FROM domain_console_scenarios WHERE id = ?", (scenario_id,) + ).fetchone() + return _row_to_dict(row) if row else None + + +def get_scenario_by_domain(conn: sqlite3.Connection, domain: str) -> dict[str, Any] | None: + row = conn.execute( + """ + SELECT * FROM domain_console_scenarios + WHERE domain = ? AND status = 'active' + ORDER BY created_at DESC LIMIT 1 + """, + (domain.lower().strip(),), + ).fetchone() + return _row_to_dict(row) if row else None + + +def create_scenario( + conn: sqlite3.Connection, + *, + domain: str, + label: str, + baseline_accounts: list[str], + created_by: str, + plan_mock: str = "Business", +) -> dict[str, Any]: + scenario_id = str(uuid.uuid4()) + now = _ts() + admin_email = f"admin@{domain}" + created = [admin_email] if admin_email not in baseline_accounts else [] + conn.execute( + """ + INSERT INTO domain_console_scenarios ( + id, domain, label, plan_mock, baseline_accounts_json, + created_accounts_json, status, created_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?) + """, + ( + scenario_id, + domain.lower().strip(), + label, + plan_mock, + json.dumps(sorted(set(baseline_accounts)), ensure_ascii=False), + json.dumps(created, ensure_ascii=False), + created_by, + now, + now, + ), + ) + conn.commit() + out = get_scenario(conn, scenario_id) + assert out is not None + return out + + +def append_created_account(conn: sqlite3.Connection, scenario_id: str, email: str) -> dict[str, Any]: + row = get_scenario(conn, scenario_id) + if not row: + raise ValueError("cenário não encontrado") + created = list(row["created_accounts"]) + email = email.lower().strip() + if email not in created: + created.append(email) + now = _ts() + conn.execute( + """ + UPDATE domain_console_scenarios + SET created_accounts_json = ?, updated_at = ? + WHERE id = ? + """, + (json.dumps(created, ensure_ascii=False), now, scenario_id), + ) + conn.commit() + out = get_scenario(conn, scenario_id) + assert out is not None + return out diff --git a/projects/ops-desk/api/app/main.py b/projects/ops-desk/api/app/main.py index 0b5a42f..df48aab 100644 --- a/projects/ops-desk/api/app/main.py +++ b/projects/ops-desk/api/app/main.py @@ -19,6 +19,7 @@ from app.assist_routes import router as assist_router, process_escalation_webhoo from app.crm_routes import router as crm_router from app import crm_leads, integration_health from app.cloudflare_dns import fetch_domain_dns +from app.dns_viewer import fetch_dns_viewer from app.modules.routes import router as modules_router from app.vm112_domains_routes import router as vm112_domains_router from app.purge_auth_routes import router as purge_auth_router @@ -30,8 +31,11 @@ from app.infra_stack_routes import router as infra_stack_router from app.vm123.routes import router as vm123_router from app.agents.routes import router as agents_router from app.rbac_routes import router as rbac_router +from app.domain_console_sandbox_routes import router as domain_console_sandbox_router +from app.domain_console_routes import router as domain_console_router from app.agents.store import init_agent_schema from app.collectors.base import run_audit +from app.openpanel_dns import fetch_openpanel_bind_records from app.permissions import ( can_assign_ticket, can_list_webhook_events, @@ -39,6 +43,7 @@ from app.permissions import ( can_read_audit_overview, can_read_audit_scorecard, can_read_cloudflare_dns, + can_read_dns_viewer, can_read_funnel, can_read_session_timeline, can_read_tickets, @@ -49,6 +54,7 @@ from app.permissions import ( DB_PATH = Path(os.getenv("SQLITE_PATH", "/data/ops.db")) REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090") +DNS_VIEWER_ENABLED = os.getenv("DNS_VIEWER_ENABLED", "1").lower() in ("1", "true", "yes") MAIL_PUBLIC_IP = os.getenv("MAIL_PUBLIC_IP", "") AUDIT_INTERVAL_SEC = int(os.getenv("AUDIT_INTERVAL_SEC", "600")) WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "ligbox-ops-dev-secret") @@ -138,6 +144,8 @@ app.include_router(infra_stack_router) app.include_router(vm123_router) app.include_router(agents_router) app.include_router(rbac_router) +app.include_router(domain_console_sandbox_router) +app.include_router(domain_console_router) TICKET_COLUMNS = "id,tenant_id,subject,status,payload,created_at,assigned_to,assigned_at,session_id,assist_mode,assisted_by,assisted_at,client_paused" @@ -194,6 +202,9 @@ def init_db(): from app import agent_bindings agent_bindings.init_schema(conn) + from app.domain_console_sandbox_store import init_schema as init_dcs_schema + + init_dcs_schema(conn) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=60000") conn.commit() @@ -1149,6 +1160,38 @@ async def cloudflare_dns_records( return await fetch_domain_dns(domain, email_service=email_service) +@app.get("/api/v1/dns/viewer/{domain}") +async def dns_viewer( + domain: str, + email_service: bool | None = Query(default=None), + include_public: bool = Query(default=True), + user: auth.DeskUser = Depends(auth.require_internal_or_user), +): + if not DNS_VIEWER_ENABLED: + raise HTTPException(404, "DNS Viewer disabled") + if not can_read_dns_viewer(user.role): + raise HTTPException(403, "insufficient permissions") + domain = domain.lower().strip().rstrip(".") + if len(domain) < 3: + raise HTTPException(400, "domain too short") + return await fetch_dns_viewer( + domain, + role=user.role, + email_service=email_service, + include_public=include_public, + ) + + +@app.get("/api/v1/dns/openpanel/records") +async def openpanel_dns_records( + domain: str = Query(..., min_length=3), + user: auth.DeskUser = Depends(auth.get_current_user), +): + if not can_read_dns_viewer(user.role): + raise HTTPException(403, "insufficient permissions") + return fetch_openpanel_bind_records(domain.lower().strip().rstrip(".")) + + @app.get("/api/v1/audit/tenants/{tenant_id}/scorecard") def audit_scorecard( tenant_id: int, diff --git a/projects/ops-desk/api/app/openpanel_dns.py b/projects/ops-desk/api/app/openpanel_dns.py new file mode 100644 index 0000000..7397062 --- /dev/null +++ b/projects/ops-desk/api/app/openpanel_dns.py @@ -0,0 +1,138 @@ +"""OpenPanel BIND DNS records (read-only) — Spec 037 V2.""" + +from __future__ import annotations + +import os +import re +from typing import Any + +from app.cloudflare_dns import _classify_record, _record_belongs +from app.collectors.dns import _dig + +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("/") + +# Ligbox authoritative BIND — common public NS hostnames +OPENPANEL_NS_HINTS = ("openpanel", "ligbox", "itecnologys") + + +def _dig_bind(name: str, rtype: str) -> list[str]: + host = OPENPANEL_BIND_HOST.strip() + if not host: + return [] + try: + import subprocess + + proc = subprocess.run( + ["dig", f"@{host}", "+short", name, rtype], + capture_output=True, + text=True, + timeout=8, + ) + if proc.returncode == 0 and proc.stdout.strip(): + return [ln.strip().strip('"') for ln in proc.stdout.splitlines() if ln.strip()] + except Exception: + pass + return [] + + +def _parse_mx(line: str) -> tuple[int | None, str]: + m = re.match(r"(\d+)\s+(.+)", line.strip()) + if m: + return int(m.group(1)), m.group(2).rstrip(".") + return None, line.strip().rstrip(".") + + +def _names_to_query(domain: str) -> list[str]: + domain = domain.lower().strip().rstrip(".") + names = {domain, f"mail.{domain}", f"www.{domain}", f"_dmarc.{domain}", f"default._domainkey.{domain}"} + return sorted(names) + + +def ns_points_to_openpanel(domain: str) -> bool: + for ns in _dig(domain, "NS"): + ns_l = ns.lower().rstrip(".") + if any(h in ns_l for h in OPENPANEL_NS_HINTS): + return True + for a in _dig(ns_l, "A"): + if a == OPENPANEL_PUBLIC_IP or a == OPENPANEL_BIND_HOST: + return True + return False + + +def bind_zone_responds(domain: str) -> bool: + host = OPENPANEL_BIND_HOST + if not host: + return False + try: + import subprocess + + proc = subprocess.run( + ["dig", f"@{host}", "+norecurse", domain, "SOA", "+short"], + capture_output=True, + text=True, + timeout=8, + ) + return proc.returncode == 0 and bool(proc.stdout.strip()) + except Exception: + return False + + +def fetch_openpanel_bind_records(domain: str) -> dict[str, Any]: + """List records from OpenPanel BIND via dig @ bind host (read-only).""" + domain = domain.lower().strip().rstrip(".") + rows: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + + def add(name: str, rtype: str, content: str, priority: int | None = None) -> None: + name = name.rstrip(".").lower() + content = content.strip().strip('"') + key = (name, rtype, content) + if not content or key in seen: + return + if not _record_belongs(name, domain): + return + seen.add(key) + purpose = _classify_record(name, rtype, content) + rows.append( + { + "source": "openpanel_bind", + "status": "applied", + "type": rtype, + "name": name, + "content": content, + "priority": priority, + "ttl": None, + "purpose": purpose, + "email_related": purpose + in ("mx", "spf", "dkim", "dmarc", "mail-host", "autodiscover", "mail-alias"), + } + ) + + for name in _names_to_query(domain): + for rtype in ("A", "AAAA", "CNAME", "TXT"): + for line in _dig_bind(name, rtype): + if rtype == "CNAME" and line.endswith("."): + line = line.rstrip(".") + add(name, rtype, line) + + for line in _dig_bind(name, "MX"): + prio, target = _parse_mx(line) + add(name, "MX", target, priority=prio) + + rows.sort(key=lambda r: (0 if r["email_related"] else 1, r["type"], r["name"])) + + return { + "domain": domain, + "bind_host": OPENPANEL_BIND_HOST, + "zone_responds": bind_zone_responds(domain), + "ns_on_openpanel": ns_points_to_openpanel(domain), + "records": rows, + "summary": { + "total": len(rows), + "email_related": sum(1 for r in rows if r.get("email_related")), + }, + "edit_url": f"{OPENPANEL_URL}/domains/{domain}/dns", + "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 ef93de4..a7c81a5 100644 --- a/projects/ops-desk/api/app/permissions.py +++ b/projects/ops-desk/api/app/permissions.py @@ -124,6 +124,19 @@ def can_read_cloudflare_dns(role: str) -> bool: ) +def can_read_dns_viewer(role: str) -> bool: + """Spec 037-DNS-VIEWER — same read surface as Cloudflare DNS for V1.""" + return can_read_cloudflare_dns(role) + + +def can_open_cloudflare_dns_link(role: str) -> bool: + return role in ("super_admin", "ops_lead", "devops", "seo") + + +def can_open_openpanel_link(role: str) -> bool: + return role in ("super_admin", "ops_lead", "sales_admin", "sales_support", "seo", "devops") + + def can_read_funnel(role: str) -> bool: return role in ( "super_admin", diff --git a/projects/ops-desk/frontend/assets/app.js b/projects/ops-desk/frontend/assets/app.js index 005b164..892feeb 100644 --- a/projects/ops-desk/frontend/assets/app.js +++ b/projects/ops-desk/frontend/assets/app.js @@ -900,6 +900,9 @@ function dnsPurposeLabel(purpose) { } async function fetchCloudflareDns(domain, emailService) { + if (window.DnsViewer?.fetchDomainDns) { + return window.DnsViewer.fetchDomainDns(domain, emailService); + } try { return await api( `/v1/dns/cloudflare/records?domain=${encodeURIComponent(domain)}&email_service=${emailService ? 'true' : 'false'}` @@ -942,7 +945,7 @@ async function showOverviewHomeDnsPanel(domain, tenantId, funnelStage, domainMet const timingCard = phaseTimingCardHtml(timing, timeline); const dns = await fetchCloudflareDns(domain, isEmailServiceDomain(tenantId, funnelStage)); - panel.innerHTML = `${timingCard}${htmlCloudflareDnsCardInline(dns)}`; + panel.innerHTML = `${timingCard}${window.DnsViewer?.renderPanel ? window.DnsViewer.renderPanel(dns, { compact: true }) : htmlCloudflareDnsCardInline(dns)}`; } function htmlCloudflareDnsCardInline(dns) { @@ -1651,7 +1654,7 @@ async function openOverviewDomainDetail(domain) {
Ticket
${d.ticket_id ? `#${d.ticket_id} (${esc(d.ticket_status || '—')})` : '—'}
${ips.length > 1 ? `

IPs observados: ${ips.map((ip) => `${esc(ip)}`).join(' · ')}

` : ''} - ${htmlCloudflareDnsCard(dnsData)} + ${window.DnsViewer?.renderPanel ? window.DnsViewer.renderPanel(dnsData) : htmlCloudflareDnsCard(dnsData)}