ligbox-ops-platform/deploy/vm112-wizard/perf-domains-list-20260625/infrastructure.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

380 lines
13 KiB
Python

"""Provisionamento infra: HAProxy SNI (CT114), Traefik, certificado LE, Carbonio."""
from __future__ import annotations
import re
import subprocess
from typing import Any
import httpx
from app.config import settings
from app.services import activity_log, carbonio, dns_verify, carbonio_cache
from app.services.domain_registry import get_domain_record
from app.services.mail_aliases import sanitize_mail_aliases
CERT_NAME = "mail-vm112-multi"
TRAEFIK_SERVICE = "dratcoin-Service"
SNI_MAP = "/root/traefik/haproxy-mail-sni/maps/sni_vm112.lst"
TRAEFIK_DYNAMIC = "/root/traefik/dynamic.yml"
DEPLOY_SCRIPT = "/usr/local/bin/carbonio-cert-deploy.sh"
def _mail_host(domain: str) -> str:
return f"mail.{domain.lower().strip()}"
def _router_key_for_host(mail_host: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", mail_host.lower()).strip("-")
return f"mail-{slug}-Router"
def _router_key(domain: str) -> str:
return _router_key_for_host(_mail_host(domain))
def _collect_mail_hosts(domain: str, mail_aliases: list[str] | None = None) -> list[str]:
primary = _mail_host(domain)
hosts = [primary]
for h in mail_aliases or []:
hh = h.lower().strip().rstrip(".")
if hh and hh not in hosts:
hosts.append(hh)
return hosts
def _cert_san_hostnames(all_hosts: list[str]) -> list[str]:
"""
Hostnames para certificado LE — só webmail (mail.* / webmail.*).
Exclui portal.* e outros aliases que não servem Carbonio directamente.
"""
cert: list[str] = []
for h in all_hosts:
hl = h.lower().strip()
if hl.startswith("portal."):
continue
if hl.startswith("mail.") or hl.startswith("webmail."):
cert.append(hl)
return sorted(set(cert))
def _normalize_cert_sans(existing_sans: list[str], cert_hosts: list[str]) -> list[str]:
"""Mantém SANs activos no certificado multi-domínio."""
result = set(cert_hosts)
for san in existing_sans:
s = san.lower().strip()
if not (s.startswith("mail.") or s.startswith("webmail.")):
continue
if s.startswith("portal."):
continue
result.add(s)
return sorted(result)
def _ssh_ct114(command: str, timeout: int = 90) -> tuple[bool, str]:
host = settings.traefik_ssh_host
try:
proc = subprocess.run(
[
"ssh",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
host,
command,
],
capture_output=True,
text=True,
timeout=timeout,
)
out = (proc.stdout or "") + (proc.stderr or "")
return proc.returncode == 0, out.strip()
except (subprocess.TimeoutExpired, OSError) as e:
return False, str(e)
def _read_ct114_file(path: str) -> tuple[bool, str]:
ok, out = _ssh_ct114(f"cat {path} 2>/dev/null || true")
return ok, out
def _cert_domains() -> list[str]:
try:
proc = subprocess.run(
["certbot", "certificates"],
capture_output=True,
text=True,
timeout=60,
)
text = proc.stdout or ""
for block in text.split("Certificate Name:"):
if CERT_NAME in block:
m = re.search(r"Domains:\s*(.+)", block)
if m:
return m.group(1).split()
except (subprocess.TimeoutExpired, OSError):
pass
return []
def get_status(domain: str, mail_aliases: list[str] | None = None) -> dict[str, Any]:
domain = domain.lower().strip()
cache_key = f"infra_status:{domain}"
cached = carbonio_cache.get(cache_key)
if cached is not None:
return dict(cached)
mail = _mail_host(domain)
if mail_aliases is not None:
aliases = sanitize_mail_aliases(mail_aliases, domain)
else:
rec = get_domain_record(domain)
aliases = list(rec.get("mail_aliases") or []) if rec else []
all_hosts = _collect_mail_hosts(domain, aliases)
steps: list[dict[str, Any]] = []
# 1 Carbonio
c_ok = carbonio.domain_exists(domain)
c_msg = (
"Domínio activo no Carbonio"
if c_ok
else "Aguarda «Criar conta agora» (zmprov cd + ca)"
)
steps.append({"id": "carbonio_domain", "label": "Carbonio (domínio)", "ok": c_ok, "message": c_msg})
# 2 DNS público
try:
v = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)
d_ok = bool(v.get("ready"))
d_msg = "MX e A mail OK" if d_ok else "DNS mail incompleto"
except Exception as e:
d_ok = False
d_msg = str(e)
steps.append({"id": "dns_mail", "label": "DNS público (MX/A)", "ok": d_ok, "message": d_msg})
# 3 HAProxy SNI
ok_ssh, sni_body = _read_ct114_file(SNI_MAP)
if not ok_ssh:
s_ok, s_msg = False, f"SSH CT114 indisponível: {sni_body[:120]}"
else:
sni_lines = {ln.strip() for ln in sni_body.splitlines() if ln.strip()}
missing = [h for h in all_hosts if h not in sni_lines]
s_ok = len(missing) == 0
s_msg = (
f"{len(all_hosts)} hostname(s) no SNI"
if s_ok
else f"Faltam no SNI: {', '.join(missing)}"
)
steps.append({"id": "haproxy_sni", "label": "HAProxy SNI (CT114)", "ok": s_ok, "message": s_msg})
# 4 Traefik router
ok_ssh, dyn = _read_ct114_file(TRAEFIK_DYNAMIC)
if not ok_ssh:
t_ok, t_msg = False, f"SSH CT114: {dyn[:120]}"
else:
missing_t = [h for h in all_hosts if f"Host(`{h}`)" not in dyn]
t_ok = len(missing_t) == 0
t_msg = (
f"Routers Traefik ({len(all_hosts)} hostnames)"
if t_ok
else f"Faltam routers: {', '.join(missing_t)}"
)
steps.append({"id": "traefik_router", "label": "Traefik HTTPS (CT114)", "ok": t_ok, "message": t_msg})
# 5 Cert SAN (só hostnames mail.* — não portal.*)
cert_hosts = _cert_san_hostnames(all_hosts)
sans = _cert_domains()
missing_cert = [h for h in cert_hosts if h not in sans]
cert_ok = len(missing_cert) == 0
steps.append(
{
"id": "cert_san",
"label": "Certificado LE (VM112)",
"ok": cert_ok,
"message": (
f"SAN inclui {len(cert_hosts)} hostname(s) webmail"
if cert_ok
else f"Faltam no cert: {', '.join(missing_cert)}"
),
}
)
# 6 Webmail HTTPS
try:
with httpx.Client(timeout=12.0, verify=False) as client:
r = client.get(f"https://{mail}/", follow_redirects=True)
w_ok = r.status_code < 500
w_msg = f"HTTPS {r.status_code}" if w_ok else f"HTTPS erro {r.status_code}"
except Exception as e:
w_ok = False
w_msg = str(e)[:80]
steps.append({"id": "webmail_https", "label": "Webmail HTTPS", "ok": w_ok, "message": w_msg})
all_ok = all(s["ok"] for s in steps)
result = {
"domain": domain,
"mail_host": mail,
"mail_aliases": aliases,
"mail_hosts": all_hosts,
"steps": steps,
"ready": all_ok,
}
carbonio_cache.set(cache_key, result, carbonio_cache.TTL_INFRA_STATUS)
return result
def provision(
domain: str,
step_id: str | None = None,
mail_aliases: list[str] | None = None,
) -> dict[str, Any]:
domain = domain.lower().strip()
if mail_aliases is not None:
aliases = sanitize_mail_aliases(mail_aliases, domain)
else:
rec = get_domain_record(domain)
aliases = list(rec.get("mail_aliases") or []) if rec else []
all_hosts = _collect_mail_hosts(domain, aliases)
results: list[dict[str, Any]] = []
def run(step: str, fn) -> None:
if step_id and step_id != step:
return
activity_log.info(f"Infra: iniciar {step}", source="traefik")
try:
msg = fn()
activity_log.ok(f"Infra {step}: {msg}", source="traefik")
results.append({"id": step, "ok": True, "message": msg})
except Exception as e:
activity_log.error(f"Infra {step}: {e}", source="traefik")
results.append({"id": step, "ok": False, "message": str(e)})
def do_sni() -> str:
ok, body = _read_ct114_file(SNI_MAP)
if not ok:
raise RuntimeError(body)
added: list[str] = []
for host in all_hosts:
if host in body.splitlines():
continue
cmd = f"grep -qxF '{host}' {SNI_MAP} || echo '{host}' >> {SNI_MAP}"
ok2, out = _ssh_ct114(cmd, timeout=60)
if not ok2:
raise RuntimeError(out)
added.append(host)
if added:
ok3, out = _ssh_ct114(
"cd /root/traefik && docker compose restart mail-sni-proxy 2>&1 | tail -2",
timeout=120,
)
if not ok3:
raise RuntimeError(out)
return f"SNI: {len(all_hosts)} hostname(s)" + (f" (+{len(added)} novos)" if added else " (já OK)")
def do_traefik() -> str:
ok, body = _read_ct114_file(TRAEFIK_DYNAMIC)
if not ok:
raise RuntimeError(body)
added: list[str] = []
for host in all_hosts:
if f"Host(`{host}`)" in body:
continue
r_key = _router_key_for_host(host)
remote = f"""python3 <<'PY'
from pathlib import Path
p = Path("{TRAEFIK_DYNAMIC}")
text = p.read_text()
mail = "{host}"
rkey = "{r_key}"
svc = "{TRAEFIK_SERVICE}"
if f"Host(`{{mail}}`)" in text:
print("exists")
else:
block = f'''
{{rkey}}:
rule: Host(`{{mail}}`)
service: {{svc}}
entryPoints:
- websecure
tls:
certResolver: letsencrypt
middlewares:
- default-headers
'''
idx = text.find(" services:")
if idx < 0:
raise SystemExit("services: não encontrado")
p.write_text(text[:idx] + block + text[idx:])
print("added")
PY"""
ok2, out = _ssh_ct114(remote, timeout=90)
if not ok2:
raise RuntimeError(out)
added.append(host)
if added:
ok3, out2 = _ssh_ct114(
"cd /root/traefik && docker compose restart traefik 2>&1 | tail -3",
timeout=120,
)
if not ok3:
raise RuntimeError(out2)
return f"Traefik: {len(all_hosts)} hostname(s)" + (f" (+{len(added)} routers)" if added else " (já OK)")
def do_cert() -> str:
cert_hosts = _cert_san_hostnames(all_hosts)
sans = _cert_domains()
missing = [h for h in cert_hosts if h not in sans]
if not missing:
return f"SAN já inclui {len(cert_hosts)} hostname(s) webmail"
try:
from app.services.cloudflare import CloudflareDNS
zone = CloudflareDNS().get_zone_by_name(domain)
if zone and zone.get("status") != "active":
ns = ", ".join(zone.get("name_servers") or [])
raise RuntimeError(
f"Zona Cloudflare «{domain}» ainda não activa (status: {zone.get('status')}). "
f"Altere os nameservers no registrador para: {ns}"
)
except RuntimeError:
raise
except Exception:
pass
new_domains = _normalize_cert_sans(sans, cert_hosts)
dom_args = " ".join(f"-d {d}" for d in new_domains)
creds = settings.certbot_cloudflare_credentials
cmd = (
f"certbot certonly --dns-cloudflare "
f"--dns-cloudflare-credentials {creds} "
f"--dns-cloudflare-propagation-seconds 90 "
f"--cert-name {CERT_NAME} --expand {dom_args} "
f"--non-interactive --agree-tos"
)
proc = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=600)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "certbot falhou")[:500]
raise RuntimeError(
f"{err} — confirme NS Cloudflare do domínio e aguarde propagação DNS."
)
lineage = f"/etc/letsencrypt/live/{CERT_NAME}"
deploy = subprocess.run(
f"RENEWED_LINEAGE={lineage} {DEPLOY_SCRIPT}",
shell=True,
capture_output=True,
text=True,
timeout=180,
)
if deploy.returncode != 0:
raise RuntimeError((deploy.stderr or deploy.stdout or "deploy falhou")[:300])
return f"certificado expandido ({len(new_domains)} SANs) e deploy Carbonio"
run("haproxy_sni", do_sni)
run("traefik_router", do_traefik)
if step_id in (None, "cert_san", "carbonio_cert"):
if not step_id or step_id == "cert_san":
run("cert_san", do_cert)
status = get_status(domain, aliases)
return {"domain": domain, "results": results, "status": status, "mail_aliases": aliases}