ligbox-ops-platform/projects/ops-desk/api/app/domain_console_sandbox.py
Ligbox Spec Hub 038fb8f7ce feat(dns): Spec 037 DNS Viewer — Desk, Console e patches Wizard V4
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 <cursoragent@cursor.com>
2026-06-25 16:36:25 +00:00

316 lines
9.6 KiB
Python

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