ligbox-ops-platform/deploy/vm112-wizard/perf-domains-list-20260625/carbonio.py
Ligbox Spec Hub edffd8b3c0 feat(desk): Serviços IaaS perf, Escopo OPS cards e blocklist UI (Spec 017/018)
Cache VM112/VM122 para lista e detalhe domínio, modal purge com loading animado,
cards Escopo OPS clicáveis (camada + Spec + navegação), blocklist visível na UI,
e documentação nas specs e anais de referência 20260625.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 18:53:57 +00:00

485 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Provisionamento local via zmprov (Carbonio)."""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from app.config import settings
NGINX_WEB_HTTPS = Path("/opt/zextras/conf/nginx/includes/nginx.conf.web.https")
ZMPROXYCONFGEN = "/opt/zextras/libexec/zmproxyconfgen"
from app.services import activity_log, carbonio_cache
EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
_NO_SUCH_DOMAIN = "NO_SUCH_DOMAIN"
_NO_SUCH_ACCOUNT = "NO_SUCH_ACCOUNT"
class CarbonioError(Exception):
pass
def _zmprov_run(*args: str, log_cmd: bool = True) -> tuple[int, str, str]:
cmd = ["sudo", "-u", settings.zextras_user, settings.zmprov, *args]
if log_cmd:
activity_log.cmd(" ".join(cmd), source="vm112")
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return proc.returncode, (proc.stdout or "").strip(), (proc.stderr or "").strip()
def _run_zmprov(*args: str) -> str:
"""Comando zmprov que deve ter sucesso (criar domínio/conta, etc.)."""
code, out, err = _zmprov_run(*args, log_cmd=True)
if code != 0:
msg = err or out or "zmprov failed"
activity_log.error(f"zmprov falhou: {msg}", source="vm112")
raise CarbonioError(msg)
if out:
activity_log.ok(out[:500], source="vm112")
return out
def _is_missing_domain(stderr: str, stdout: str) -> bool:
blob = f"{stderr}\n{stdout}"
return _NO_SUCH_DOMAIN in blob
def _is_missing_account(stderr: str, stdout: str) -> bool:
blob = f"{stderr}\n{stdout}"
return _NO_SUCH_ACCOUNT in blob or _NO_SUCH_DOMAIN in blob
def _is_account_exists(stderr: str, stdout: str) -> bool:
blob = f"{stderr}\n{stdout}"
return "ACCOUNT_EXISTS" in blob
def _carbonio_unreachable(err: str, out: str) -> bool:
blob = f"{err}\n{out}".lower()
return "connection refused" in blob or "io_error" in blob
def domain_exists(domain: str, *, use_cache: bool = True) -> bool:
"""Verificação silenciosa — domínio inexistente NÃO é erro de onboarding."""
domain = domain.lower().strip()
cache_key = f"domain_exists:{domain}"
if use_cache:
cached = carbonio_cache.get(cache_key)
if cached is not None:
return bool(cached)
code, out, err = _zmprov_run("gd", domain, log_cmd=False)
if code == 0:
exists = f"# name {domain}" in out or f"name {domain}" in out
elif _is_missing_domain(err, out):
exists = False
elif _carbonio_unreachable(err, out):
exists = False
else:
activity_log.warn(f"zmprov gd {domain}: {err or out}", source="vm112")
exists = False
if use_cache:
carbonio_cache.set(cache_key, exists, carbonio_cache.TTL_DOMAIN_EXISTS)
return exists
def list_all_domains(*, use_cache: bool = True) -> list[str]:
"""zmprov gad (~5s) — cache TTL curto para Serviços IaaS / Desk."""
cache_key = carbonio_cache.CACHE_KEY_ALL_DOMAINS
if use_cache:
cached = carbonio_cache.get(cache_key)
if cached is not None:
return list(cached)
code, out, _err = _zmprov_run("gad", log_cmd=False)
if code != 0:
return []
domains = [ln.strip().lower() for ln in out.splitlines() if ln.strip()]
if use_cache:
carbonio_cache.set(cache_key, domains, carbonio_cache.TTL_ALL_DOMAINS)
return domains
def invalidate_domain_list_cache() -> None:
carbonio_cache.invalidate_all_domains()
def set_domain_public_hostname(domain: str) -> str:
"""Webmail mail.{domínio} — evita redirect para mail.ligbox.com.br."""
domain = domain.lower().strip()
mail_host = f"mail.{domain}"
logo = f"https://{mail_host}/public/logos/ligbox-logo.png"
admin = f"admin@{domain}"
_run_zmprov(
"md",
domain,
"zimbraPublicServiceHostname",
mail_host,
"zimbraVirtualHostname",
mail_host,
"zimbraPublicServiceProtocol",
"https",
"zimbraPublicServicePort",
"443",
"zimbraSkinLogoURL",
logo,
"carbonioLogoUrl",
logo,
"carbonioNotificationFrom",
admin,
"carbonioNotificationRecipients",
admin,
)
return mail_host
def add_mail_alias_hostnames(domain: str, hostnames: list[str]) -> None:
"""Adiciona hostnames virtuais (alias webmail) ao domínio Carbonio."""
domain = domain.lower().strip()
primary_mail = f"mail.{domain}"
for host in hostnames:
h = host.lower().strip().rstrip(".")
if not h or h == primary_mail:
continue
try:
_run_zmprov("md", domain, "zimbraVirtualHostname", h)
activity_log.ok(f"Alias hostname Carbonio: {h}", source="vm112")
except CarbonioError as e:
activity_log.warn(f"Alias {h}: {e}", source="vm112")
def create_domain(domain: str) -> str:
domain = domain.lower().strip()
if domain_exists(domain):
activity_log.info(f"Domínio {domain} já existe no Carbonio", source="vm112")
set_domain_public_hostname(domain)
return f"domain {domain} already exists"
activity_log.info(f"Criar domínio no Carbonio: {domain}", source="vm112")
out = _run_zmprov("cd", domain)
set_domain_public_hostname(domain)
invalidate_domain_list_cache()
activity_log.ok(f"Webmail: https://mail.{domain}/", source="vm112")
return out
def create_account(email: str, password: str, display_name: str | None = None) -> str:
return create_account_full(
email,
password,
display_name=display_name,
quota_mb=0,
must_change_password=False,
)
def ensure_onboarding_account(
email: str,
password: str,
display_name: str | None = None,
) -> tuple[str, bool]:
"""
Cria conta admin no onboarding ou reutiliza se já existir (retry do wizard).
Returns (message, reused).
"""
email = email.lower().strip()
domain = email.split("@", 1)[1]
if account_exists(email):
activity_log.info(
f"Conta {email} já existe no Carbonio — actualizar senha e continuar",
source="vm112",
)
if display_name:
try:
_run_zmprov("ma", email, "displayName", display_name)
except CarbonioError:
pass
set_password(email, password)
set_password_must_change(email, False)
invalidate_domain_cache(domain)
return f"account {email} already exists (password updated)", True
msg = create_account(email, password, display_name)
return msg, False
def create_account_full(
email: str,
password: str,
*,
display_name: str | None = None,
quota_mb: int = 0,
must_change_password: bool = True,
) -> str:
"""Uma única chamada zmprov ca (evita 34 round-trips de ~5s cada)."""
if not EMAIL_RE.match(email):
raise CarbonioError("invalid email")
domain = email.split("@", 1)[1]
if not domain_exists(domain):
create_domain(domain)
activity_log.info(f"Criar conta: {email}", source="vm112")
args = ["ca", email, password]
if display_name:
args.extend(["displayName", display_name])
if quota_mb > 0:
args.extend(["zimbraMailQuota", str(int(quota_mb) * 1024 * 1024)])
if must_change_password:
args.extend(["zimbraPasswordMustChange", "TRUE"])
out = _run_zmprov(*args)
invalidate_domain_cache(domain)
return out
def account_summary_fast(
email: str,
*,
display_name: str | None = None,
quota_mb: int = 0,
must_change_password: bool = True,
) -> dict:
"""Resposta imediata sem zmprov ga (~5s)."""
quota_bytes = int(quota_mb) * 1024 * 1024 if quota_mb > 0 else 0
return {
"email": email.lower(),
"display_name": display_name or "",
"status": "active",
"active": True,
"suspended": False,
"quota_bytes": quota_bytes,
"quota_label": _quota_label(str(quota_bytes)),
"two_factor_enabled": False,
"password_must_change": must_change_password,
"last_logon": None,
}
def list_accounts(domain: str, *, use_cache: bool = True) -> list[str]:
"""Lista contas do domínio (zmprov -l gaa ~35s — cache TTL curto)."""
domain = domain.lower().strip()
cache_key = f"accounts_list:{domain}"
if use_cache:
cached = carbonio_cache.get(cache_key)
if cached is not None:
return list(cached)
code, out, err = _zmprov_run("-l", "gaa", domain, log_cmd=False)
if code != 0:
msg = err or out or "zmprov gaa failed"
raise CarbonioError(msg)
result = sorted(line.strip() for line in out.splitlines() if line.strip())
if use_cache:
carbonio_cache.set(cache_key, result, carbonio_cache.TTL_ACCOUNTS_LIST)
return result
def set_password(email: str, password: str) -> str:
return _run_zmprov("sp", email, password)
def account_exists(email: str) -> bool:
code, out, err = _zmprov_run("ga", email, log_cmd=False)
if code == 0:
return email.lower() in out.lower()
if _is_missing_account(err, out):
return False
activity_log.warn(f"zmprov ga {email}: {err or out}", source="vm112")
return False
def _parse_ga_attributes(out: str) -> dict[str, str]:
attrs: dict[str, str] = {}
for line in out.splitlines():
if ":" not in line:
continue
key, _, val = line.partition(":")
attrs[key.strip()] = val.strip()
return attrs
def get_account_attributes(email: str) -> dict[str, str]:
code, out, err = _zmprov_run("ga", email, log_cmd=False)
if code != 0:
raise CarbonioError(err or out or f"Conta {email} não encontrada")
return _parse_ga_attributes(out)
def get_domain_attributes(domain: str) -> dict[str, str]:
code, out, err = _zmprov_run("gd", domain, log_cmd=False)
if code != 0:
raise CarbonioError(err or out or f"Domínio {domain} não encontrado")
return _parse_ga_attributes(out)
def _quota_label(bytes_raw: str) -> str:
try:
n = int(bytes_raw)
except (TypeError, ValueError):
return bytes_raw or ""
if n <= 0:
return "Ilimitada (COS)"
if n >= 1024**3:
return f"{n / 1024**3:.1f} GB"
if n >= 1024**2:
return f"{n / 1024**2:.0f} MB"
return f"{n} B"
def account_summary(email: str, *, use_cache: bool = True) -> dict:
email = email.lower().strip()
cache_key = f"account_summary:{email}"
if use_cache:
cached = carbonio_cache.get(cache_key)
if cached is not None:
return cached
attrs = get_account_attributes(email)
status = attrs.get("zimbraAccountStatus", "unknown")
quota_b = attrs.get("zimbraMailQuota", "0")
tfa = attrs.get("zimbraTwoFactorAuthEnabled", "").upper() == "TRUE"
must_change = attrs.get("zimbraPasswordMustChange", "").upper() == "TRUE"
last_logon = attrs.get("zimbraLastLogonTimestamp", "")
if last_logon and len(last_logon) >= 8:
last_logon = f"{last_logon[0:4]}-{last_logon[4:6]}-{last_logon[6:8]}"
summary = {
"email": email.lower(),
"display_name": attrs.get("displayName") or attrs.get("cn") or "",
"status": status,
"active": status == "active",
"suspended": status in ("locked", "closed", "maintenance"),
"quota_bytes": int(quota_b) if quota_b.isdigit() else 0,
"quota_label": _quota_label(quota_b),
"two_factor_enabled": tfa,
"password_must_change": must_change,
"last_logon": last_logon or None,
"_summary": True,
}
if use_cache:
carbonio_cache.set(cache_key, summary, carbonio_cache.TTL_ACCOUNT_SUMMARY)
return summary
def list_domain_accounts_fast(domain: str, *, use_cache: bool = True) -> list[dict]:
"""Uma chamada zmprov -l gaa (~5s) em vez de ga por conta (~5s × N)."""
domain = domain.lower().strip()
cache_key = f"accounts_fast:{domain}"
if use_cache:
cached = carbonio_cache.get(cache_key)
if cached is not None:
return cached
emails = list_accounts(domain)
result = [
{
"email": e,
"display_name": "",
"status": "",
"active": True,
"suspended": False,
"quota_label": "",
"two_factor_enabled": False,
"password_must_change": False,
"last_logon": None,
"_summary": False,
}
for e in emails
]
if use_cache:
carbonio_cache.set(cache_key, result, carbonio_cache.TTL_ACCOUNTS_LIST)
return result
def list_domain_accounts(domain: str, *, full: bool = False, use_cache: bool = True) -> list[dict]:
if not full:
return list_domain_accounts_fast(domain, use_cache=use_cache)
domain = domain.lower().strip()
emails = list_accounts(domain)
result = []
for email in emails:
try:
result.append(account_summary(email, use_cache=use_cache))
except CarbonioError:
result.append(
{
"email": email,
"status": "unknown",
"active": False,
"suspended": False,
"_summary": False,
}
)
return result
def invalidate_domain_cache(domain: str) -> None:
carbonio_cache.invalidate_domain(domain.lower().strip())
def set_account_status(email: str, active: bool) -> str:
status = "active" if active else "locked"
activity_log.info(f"Conta {email}{status}", source="vm112")
out = _run_zmprov("ma", email, "zimbraAccountStatus", status)
carbonio_cache.invalidate_account(email)
return out
def set_mail_quota(email: str, quota_mb: int) -> str:
if quota_mb <= 0:
bytes_q = "0"
else:
bytes_q = str(int(quota_mb) * 1024 * 1024)
activity_log.info(f"Quota {email}{quota_mb} MB", source="vm112")
out = _run_zmprov("ma", email, "zimbraMailQuota", bytes_q)
carbonio_cache.invalidate_account(email)
return out
def set_password_must_change(email: str, must_change: bool = True) -> str:
val = "TRUE" if must_change else "FALSE"
out = _run_zmprov("ma", email, "zimbraPasswordMustChange", val)
carbonio_cache.invalidate_account(email)
return out
def set_password_with_policy(
email: str,
password: str,
*,
must_change: bool = True,
) -> str:
out = set_password(email, password)
if must_change:
set_password_must_change(email, True)
else:
carbonio_cache.invalidate_account(email)
return out
def domain_two_factor_capabilities(domain: str) -> dict:
attrs = get_domain_attributes(domain)
avail = attrs.get("zimbraFeatureTwoFactorAuthAvailable", "FALSE").upper() == "TRUE"
required = attrs.get("zimbraFeatureTwoFactorAuthRequired", "FALSE").upper() == "TRUE"
return {
"available": avail,
"required": required,
"automated": avail,
"message": (
"2FA disponível neste domínio (política via painel activa)."
if avail
else (
"Política 2FA via painel inactiva no COS/domínio. "
"Utilizadores podem activar OTP em webmail: Settings → Auth → OTP Authentication → NEW OTP."
)
),
}
def set_user_two_factor(email: str, enabled: bool) -> str:
caps = domain_two_factor_capabilities(email.split("@", 1)[1])
if not caps["available"]:
raise CarbonioError(caps["message"])
val = "TRUE" if enabled else "FALSE"
activity_log.info(f"2FA {email}{val}", source="vm112")
return _run_zmprov("ma", email, "zimbraTwoFactorAuthEnabled", val)
def set_domain_two_factor_required(domain: str, required: bool) -> str:
caps = domain_two_factor_capabilities(domain)
if not caps["available"]:
raise CarbonioError(caps["message"])
val = "TRUE" if required else "FALSE"
return _run_zmprov("md", domain, "zimbraFeatureTwoFactorAuthRequired", val)