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>
380 lines
13 KiB
Python
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}
|