"""Listagem e purge de domínios orquestrados na VM112 (Spec 017 + 026).""" from __future__ import annotations import json import re import shutil import subprocess from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any, Callable from app.config import settings from app.services import activity_log, carbonio, carbonio_cache, domain_registry, purge_jobs from app.services.domain_site_layout import PLATFORM_DOMAINS, SITES_ROOT from app.services.infrastructure import _mail_host from app.services.traefik_purge import ( _collect_purge_hosts, purge_traefik_routers, purge_traefik_sni, rollback_traefik_backup, validate_traefik_after_purge, ) from app.services.purge_snapshot import build_audit_snapshot from app.services.purge_openpanel import purge_openpanel from app.services.purge_pmg import purge_pmg _LOG_DIR = Path("/var/log/ibytera-mail-portal/sessions") _WIZARD_PORTAL_USERS_DIR = Path("/var/lib/ligbox-wizard/portal_users") _TENANT_BRANDING = Path("/opt/ligbox-wizard/backend/app/services/tenant_branding.py") _DEPLOY_SCRIPTS = Path("/opt/ligbox-deploy/scripts/admin-login-check") _TRAEFIK_CERT_EXPORT = Path("/opt/zextras/ssl/letsencrypt/traefik-export") _NGINX_INCLUDES = Path("/opt/zextras/conf/nginx/includes") _PURGE_BLOCKLIST = PLATFORM_DOMAINS | frozenset({"itecnologys.com"}) def _domain_slug(domain: str) -> str: return domain.lower().strip().replace(".", "-") def _list_carbonio_domains() -> list[str]: return carbonio.list_all_domains() def _list_site_domains() -> list[str]: if not SITES_ROOT.is_dir(): return [] return [ p.name.lower() for p in SITES_ROOT.iterdir() if p.is_dir() and (p / "domain.json").is_file() ] def _portal_users_for_domain(domain: str, users_dir: Path | None = None) -> list[dict[str, Any]]: domain = domain.lower().strip() base = users_dir or Path(settings.portal_users_dir) if not base.is_dir(): return [] found: list[dict[str, Any]] = [] for f in base.glob("*.json"): try: data = json.loads(f.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): continue email = (data.get("planned_corporate_email") or "").lower().strip() if email.endswith(f"@{domain}"): found.append({ "login_id": data.get("login_id") or f.stem, "planned_corporate_email": email, "path": str(f), }) return found def _build_orchestrated_domains(query: str = "") -> list[dict[str, Any]]: carbonio_set = set(_list_carbonio_domains()) names = sorted(carbonio_set | set(_list_site_domains())) users_by_domain: dict[str, list[dict[str, Any]]] = {} users_dir = Path(settings.portal_users_dir) if users_dir.is_dir(): for f in users_dir.glob("*.json"): try: data = json.loads(f.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): continue email = (data.get("planned_corporate_email") or "").lower().strip() if "@" not in email: continue dom = email.split("@", 1)[1] users_by_domain.setdefault(dom, []).append({ "login_id": data.get("login_id") or f.stem, "planned_corporate_email": email, "path": str(f), }) if _WIZARD_PORTAL_USERS_DIR.is_dir(): for f in _WIZARD_PORTAL_USERS_DIR.glob("*.json"): try: data = json.loads(f.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): continue email = (data.get("planned_corporate_email") or "").lower().strip() if "@" not in email: continue dom = email.split("@", 1)[1] entry = { "login_id": data.get("login_id") or f.stem, "planned_corporate_email": email, "path": str(f), } if entry not in users_by_domain.get(dom, []): users_by_domain.setdefault(dom, []).append(entry) q = (query or "").strip().lower() items: list[dict[str, Any]] = [] for domain in names: if domain in PLATFORM_DOMAINS: continue rec = domain_registry.get_domain_record(domain) or {} admin_email = rec.get("portal_admin_email") or "" exists = domain in carbonio_set item = { "domain": domain, "mail_host": _mail_host(domain), "portal_admin_email": admin_email, "carbonio_exists": exists, "site_folder_exists": (SITES_ROOT / domain / "domain.json").is_file(), "account_count": None, "accounts_preview": [], "portal_users": users_by_domain.get(domain, []), "updated_at": rec.get("updated_at"), } if q: blob = " ".join([ domain, admin_email, " ".join(u.get("login_id", "") for u in item["portal_users"]), ]).lower() if q not in blob: continue items.append(item) return items def list_orchestrated_domains(query: str = "") -> list[dict[str, Any]]: q = (query or "").strip().lower() if not q: cached = carbonio_cache.get(carbonio_cache.CACHE_KEY_ORCH_LIST) if cached is not None: return cached items = _build_orchestrated_domains(q) if not q: carbonio_cache.set( carbonio_cache.CACHE_KEY_ORCH_LIST, items, carbonio_cache.TTL_ORCH_LIST, ) return items def list_orchestrated_domains_meta(query: str = "") -> dict[str, Any]: items = list_orchestrated_domains(query) age = carbonio_cache.entry_age_sec(carbonio_cache.CACHE_KEY_ALL_DOMAINS) return { "domains": items, "cached": age is not None, "cache_age_sec": age, } def get_domain_detail(domain: str) -> dict[str, Any]: domain = domain.lower().strip() detail_key = f"domain_detail:{domain}" cached = carbonio_cache.get(detail_key) if cached is not None: return dict(cached) matches = [i for i in list_orchestrated_domains() if i["domain"] == domain] if not matches: raise ValueError(f"Domínio {domain} não encontrado na orquestração") base = dict(matches[0]) from app.services.infrastructure import get_status rec = domain_registry.get_domain_record(domain) or {} aliases = list(rec.get("mail_aliases") or []) base["domain_record"] = rec carbonio_exists = bool(base.get("carbonio_exists")) def fetch_accounts() -> list[str]: if not carbonio_exists: return [] try: return carbonio.list_accounts(domain) except carbonio.CarbonioError: return [] def fetch_infra() -> dict[str, Any]: return get_status(domain, aliases) def fetch_cf() -> dict[str, Any]: try: from app.services.cloudflare import CloudflareDNS return CloudflareDNS().get_zone_by_name(domain) except Exception as e: return {"error": str(e)} with ThreadPoolExecutor(max_workers=3) as pool: fut_acc = pool.submit(fetch_accounts) fut_inf = pool.submit(fetch_infra) fut_cf = pool.submit(fetch_cf) base["accounts"] = fut_acc.result() base["infra_status"] = fut_inf.result() base["cloudflare_zone"] = fut_cf.result() carbonio_cache.set(detail_key, base, carbonio_cache.TTL_DOMAIN_DETAIL) return base def _delete_carbonio_accounts(domain: str) -> list[str]: logs: list[str] = [] accounts: list[str] = [] try: accounts = carbonio.list_accounts(domain) except carbonio.CarbonioError: pass for email in accounts: code, _out, _err = carbonio._zmprov_run("da", email, log_cmd=True) logs.append(f"da {email}: rc={code}") return logs def _delete_carbonio_domain_only(domain: str) -> str | None: if carbonio.domain_exists(domain, use_cache=False): code, _out, _err = carbonio._zmprov_run("dd", domain, log_cmd=True) carbonio.invalidate_domain_list_cache() return f"dd {domain}: rc={code}" return None def _purge_portal_users(domain: str) -> list[str]: removed: list[str] = [] for users_dir in (Path(settings.portal_users_dir), _WIZARD_PORTAL_USERS_DIR): for u in _portal_users_for_domain(domain, users_dir): try: Path(u["path"]).unlink(missing_ok=True) label = f"{u['login_id']}@{users_dir.name}" if label not in removed: removed.append(label) except OSError as e: removed.append(f"ERR:{u['login_id']}:{e}") return removed def _purge_site_folder(domain: str) -> bool: path = SITES_ROOT / domain if path.is_dir(): shutil.rmtree(path) return True return False def _purge_cloudflare_zone(domain: str) -> str: try: from app.services.cloudflare import CloudflareDNS, CloudflareError cf = CloudflareDNS() zone = cf.get_zone_by_name(domain) if not zone: return "no_zone" zid = zone.get("id") with cf._client() as c: r = c.delete(f"https://api.cloudflare.com/client/v4/zones/{zid}") data = r.json() if data.get("success"): return f"deleted:{zid}" raise CloudflareError(str(data.get("errors"))) except Exception as e: return f"error:{e}" def _purge_tenant_branding(domain: str) -> str: if not _TENANT_BRANDING.is_file(): return "skip" text = _TENANT_BRANDING.read_text(encoding="utf-8") needle = f'"{domain}"' if needle not in text: return "absent" new_text = re.sub(rf'^\s*"{re.escape(domain)}".*\n', "", text, flags=re.MULTILINE) _TENANT_BRANDING.write_text(new_text, encoding="utf-8") return "removed" def _purge_deploy_script_refs(domain: str) -> str: mail_host = _mail_host(domain) changed = [] for name in ("apply-admin-nginx-overrides.py", "sync-traefik-admin-certs.sh"): path = _DEPLOY_SCRIPTS / name if not path.is_file(): continue text = path.read_text(encoding="utf-8") if mail_host not in text: continue new_text = re.sub(rf'^\s*"{re.escape(mail_host)}".*\n', "", text, flags=re.MULTILINE) new_text = new_text.replace(f" {mail_host}", "") new_text = new_text.replace(f"'{mail_host}'", "") path.write_text(new_text, encoding="utf-8") changed.append(name) return ",".join(changed) if changed else "none" def _purge_traefik_export_certs(domain: str) -> str: slug = _domain_slug(domain) pattern = f"mail-{slug}*" removed = 0 if _TRAEFIK_CERT_EXPORT.is_dir(): for f in _TRAEFIK_CERT_EXPORT.glob(pattern): f.unlink(missing_ok=True) removed += 1 return str(removed) def _remove_nginx_server_block(text: str, server_name: str) -> tuple[str, bool]: pattern = re.compile( rf"server\s*\{{\s*\n\s*server_name\s+{re.escape(server_name)}\b", re.MULTILINE, ) match = pattern.search(text) if not match: return text, False start = match.start() brace = 0 i = text.find("{", start) while i < len(text): if text[i] == "{": brace += 1 elif text[i] == "}": brace -= 1 if brace == 0: end = i + 1 while end < len(text) and text[end] in "\r\n": end += 1 return text[:start] + text[end:], True i += 1 return text, False def _purge_nginx_vhosts(domain: str, hosts: list[str]) -> str: if not _NGINX_INCLUDES.is_dir(): return "skip" targets = [ _NGINX_INCLUDES / "nginx.conf.web.https", _NGINX_INCLUDES / "nginx.conf.web.admin", _NGINX_INCLUDES / "nginx.conf.web.http", _NGINX_INCLUDES / "nginx.conf.mail.imap", _NGINX_INCLUDES / "nginx.conf.mail.imaps", _NGINX_INCLUDES / "nginx.conf.mail.pop3", _NGINX_INCLUDES / "nginx.conf.mail.pop3s", _NGINX_INCLUDES / "nginx.conf.map.key", _NGINX_INCLUDES / "nginx.conf.map.crt", ] edited = 0 for fp in targets: if not fp.is_file(): continue text = fp.read_text(encoding="utf-8") orig = text for host in hosts: while True: text, removed = _remove_nginx_server_block(text, host) if not removed: break for host in hosts: text = re.sub(rf"^{re.escape(host)} .*\n", "", text, flags=re.MULTILINE) if text != orig: fp.with_suffix(fp.suffix + f".bak-purge-{_domain_slug(domain)}").write_text(orig, encoding="utf-8") fp.write_text(text, encoding="utf-8") edited += 1 if edited == 0: return "none" proc = subprocess.run( ["/opt/zextras/common/sbin/nginx", "-t", "-c", "/opt/zextras/conf/nginx.conf"], capture_output=True, text=True, timeout=60, ) if proc.returncode != 0: return f"nginx_test_fail:{(proc.stderr or proc.stdout)[:120]}" subprocess.run( ["su", "-", "zextras", "-c", "/opt/zextras/common/sbin/nginx -s reload"], capture_output=True, timeout=60, ) return f"updated:{edited}" def _purge_session_logs(domain: str) -> int: count = 0 if not _LOG_DIR.is_dir(): return 0 for f in _LOG_DIR.glob("*.jsonl"): try: if domain in f.read_text(encoding="utf-8", errors="ignore").lower(): f.unlink() count += 1 except OSError: pass return count def _execute_purge( domain: str, report: Callable[[str, str, str | None], None] | None = None, *, job_id: str | None = None, requested_by: str | None = None, ) -> dict[str, Any]: def _step(step_id: str, status: str, detail: str | None = None) -> None: if report: report(step_id, status, detail) if status == "running": activity_log.info(f"PURGE [{step_id}] {detail or '…'}", source="admin") elif status == "done": activity_log.ok(f"PURGE [{step_id}] {detail or 'OK'}", source="admin") elif status == "error": activity_log.error(f"PURGE [{step_id}] {detail or 'erro'}", source="admin") rec = domain_registry.get_domain_record(domain) or {} aliases = list(rec.get("mail_aliases") or []) hosts = _collect_purge_hosts(domain, aliases) result: dict[str, Any] = {"domain": domain} traefik_backup: str | None = None _step("audit_snapshot", "running") snapshot = build_audit_snapshot(domain, job_id=job_id, requested_by=requested_by) result["audit_snapshot"] = snapshot ac = snapshot.get("carbonio", {}).get("account_count", 0) _step("audit_snapshot", "done", f"{ac} conta(s), inventário gravado") _step("carbonio_accounts", "running") result["carbonio_accounts"] = _delete_carbonio_accounts(domain) _step("carbonio_accounts", "done", "; ".join(result["carbonio_accounts"]) or "nenhuma conta") _step("carbonio_domain", "running") dd = _delete_carbonio_domain_only(domain) result["carbonio_domain"] = dd or "domínio já ausente" _step("carbonio_domain", "done", result["carbonio_domain"]) _step("portal_users", "running") result["portal_users_removed"] = _purge_portal_users(domain) _step("portal_users", "done", ", ".join(result["portal_users_removed"]) or "nenhum") _step("site_folder", "running") result["site_folder_removed"] = _purge_site_folder(domain) _step("site_folder", "done", "removido" if result["site_folder_removed"] else "já ausente") _step("cloudflare", "running") result["cloudflare"] = _purge_cloudflare_zone(domain) _step("cloudflare", "done", result["cloudflare"]) _step("session_logs", "running") result["session_logs_removed"] = _purge_session_logs(domain) _step("session_logs", "done", str(result["session_logs_removed"])) _step("tenant_branding", "running") result["tenant_branding"] = _purge_tenant_branding(domain) _step("tenant_branding", "done", result["tenant_branding"]) _step("deploy_scripts", "running") result["deploy_scripts"] = _purge_deploy_script_refs(domain) _step("deploy_scripts", "done", result["deploy_scripts"]) _step("traefik_export_certs", "running") result["traefik_export_certs"] = _purge_traefik_export_certs(domain) _step("traefik_export_certs", "done", result["traefik_export_certs"]) _step("nginx_vhosts", "running") result["nginx_vhosts"] = _purge_nginx_vhosts(domain, hosts) nginx_ok = not str(result["nginx_vhosts"]).startswith("nginx_test_fail") _step("nginx_vhosts", "done" if nginx_ok else "error", result["nginx_vhosts"]) try: _step("traefik_sni", "running") result["traefik_sni"] = purge_traefik_sni(hosts) sni_ok = not str(result["traefik_sni"]).startswith("sni_fail") _step("traefik_sni", "done" if sni_ok else "error", result["traefik_sni"]) _step("traefik_routers", "running") tr = purge_traefik_routers(domain, hosts) result["traefik_routers"] = tr.get("detail") or ("traefik_ok" if tr.get("ok") else "traefik_fail") traefik_backup = tr.get("backup") result["traefik_backup"] = traefik_backup tr_ok = bool(tr.get("ok")) _step("traefik_routers", "done" if tr_ok else "error", result["traefik_routers"]) _step("traefik_validate", "running") validation = validate_traefik_after_purge(domain) result["traefik_validate"] = validation val_detail = json.dumps(validation.get("checks") or {}, ensure_ascii=False)[:400] val_ok = bool(validation.get("ok")) if not val_ok and traefik_backup: rb = rollback_traefik_backup(traefik_backup) result["traefik_rollback"] = rb validation = validate_traefik_after_purge(domain) result["traefik_validate_after_rollback"] = validation _step("traefik_validate", "done" if val_ok else "error", val_detail) if not sni_ok or not tr_ok or not val_ok: raise RuntimeError( f"Traefik purge incompleto (sni={result['traefik_sni']}, " f"routers={result['traefik_routers']}, validate={val_detail})" ) except Exception as e: result["traefik_error"] = str(e) if report: _step("traefik_validate", "error", str(e)) raise _step("openpanel_dns", "running") op = purge_openpanel(domain) result["openpanel"] = op _step("openpanel_dns", "done", op.get("dns", "ok")) _step("openpanel_user", "running") _step("openpanel_user", "done", op.get("user", "ok")) _step("pmg_domain", "running") pmg = purge_pmg(domain) result["pmg"] = pmg _step("pmg_domain", "done", pmg.get("domains", "ok")) _step("pmg_transport", "running") _step("pmg_transport", "done", pmg.get("transport", "ok")) result["carbonio"] = result.get("carbonio_accounts", []) + ( [result["carbonio_domain"]] if result.get("carbonio_domain") else [] ) return result def run_purge_job(job_id: str) -> None: job = purge_jobs.get_job(job_id) if not job: return domain = job["domain"] report = purge_jobs.make_reporter(job_id) try: if domain in _PURGE_BLOCKLIST: raise ValueError(f"Domínio {domain} está na blocklist de purge") activity_log.info(f"PURGE job {job_id} iniciado: {domain}", source="admin") result = _execute_purge(domain, report=report, job_id=job_id) purge_jobs.complete_job(job_id, result) carbonio.invalidate_domain_list_cache() carbonio_cache.invalidate_domain(domain) activity_log.ok(f"PURGE job {job_id} concluído: {domain}", source="admin") except Exception as e: purge_jobs.fail_job(job_id, str(e)) activity_log.error(f"PURGE job {job_id} falhou: {e}", source="admin") def assert_purge_allowed(domain: str) -> None: domain = domain.lower().strip() if domain in _PURGE_BLOCKLIST: raise ValueError(f"Domínio {domain} está na blocklist de purge") def purge_domain(domain: str) -> dict[str, Any]: domain = domain.lower().strip() assert_purge_allowed(domain) activity_log.info(f"PURGE iniciado: {domain}", source="admin") job = purge_jobs.create_job(domain) job_id = job["job_id"] report = purge_jobs.make_reporter(job_id) try: result = _execute_purge(domain, report=report) purge_jobs.complete_job(job_id, result) carbonio.invalidate_domain_list_cache() carbonio_cache.invalidate_domain(domain) activity_log.ok(f"PURGE concluído: {domain}", source="admin") job_data = purge_jobs.get_job(job_id) or {} steps = purge_jobs.steps_for_desk(job_data.get("steps") or []) return {"domain": domain, "steps": steps, **result} except Exception as e: purge_jobs.fail_job(job_id, str(e)) raise def delete_carbonio_account(email: str) -> dict: """Remove uma conta Carbonio (zmprov da) — Spec 022.""" email = email.lower().strip() if "@" not in email: raise ValueError("e-mail inválido") domain = email.split("@", 1)[1] assert_purge_allowed(domain) if not carbonio.account_exists(email): return {"ok": True, "email": email, "message": "Conta já não existia", "skipped": True} code, out, err = carbonio._zmprov_run("da", email, log_cmd=True) if code != 0 and not carbonio._is_missing_account(err, out): raise carbonio.CarbonioError(err or out or f"zmprov da falhou para {email}") carbonio.invalidate_domain_cache(domain) return {"ok": True, "email": email, "message": f"Conta {email} removida do Carbonio", "rc": code}