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