Entrega read-only de apontamentos DNS (Cloudflare, OpenPanel BIND, público) no Desk e Console /admin/dominio, com spec, scripts de rollback e patches VM112 para painel lateral no passo DNS do onboarding (deploy wizard pendente). Co-authored-by: Cursor <cursoragent@cursor.com>
341 lines
13 KiB
Python
341 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Aplica patch Spec 037 em onboarding.py na VM112. Idempotente."""
|
|
from pathlib import Path
|
|
|
|
ROUTER = Path("/opt/ligbox-wizard/backend/app/routers/onboarding.py")
|
|
text = ROUTER.read_text(encoding="utf-8")
|
|
|
|
IMPORT_OLD = "from app.services.cloudflare import CloudflareDNS, CloudflareError, is_cloudflare_sandbox, mail_dns_records, wizard_nameservers"
|
|
IMPORT_NEW = """from app.services.cloudflare import CloudflareDNS, CloudflareError, is_cloudflare_sandbox, mail_dns_records, wizard_nameservers
|
|
from app.services.dns_account_registry import resolve_ligbox_zone, resolve_payload
|
|
from app.services import dns_byo_vault"""
|
|
|
|
if "dns_account_registry" not in text:
|
|
text = text.replace(IMPORT_OLD, IMPORT_NEW)
|
|
|
|
MODEL_ANCHOR = "class CloudflareApplyRequest(BaseModel):"
|
|
MODEL_INSERT = """class CloudflareConnectCustomRequest(BaseModel):
|
|
domain: str
|
|
api_token: str = Field(..., min_length=20, max_length=512)
|
|
|
|
|
|
"""
|
|
if "CloudflareConnectCustomRequest" not in text:
|
|
text = text.replace(MODEL_ANCHOR, MODEL_INSERT + MODEL_ANCHOR)
|
|
|
|
HELPER = '''
|
|
|
|
def _cf_client_for_domain(domain: str, session_id: str | None = None):
|
|
"""Resolve cliente CF: conta Ligbox, BYO sessão, ou token legacy."""
|
|
match = resolve_ligbox_zone(domain)
|
|
if match:
|
|
return match.account.client(), match.zone_id, match.dns_mode, match.account.id
|
|
if session_id:
|
|
byo = dns_byo_vault.load_byo_token(session_id, domain)
|
|
if byo:
|
|
return CloudflareDNS(token=byo.api_token), byo.zone_id, "byo_cloudflare", None
|
|
if settings.cloudflare_api_token:
|
|
cf = CloudflareDNS()
|
|
zone = cf.get_zone_by_name(domain)
|
|
if zone:
|
|
return cf, zone["id"], "ligbox_cf_legacy", None
|
|
return None, None, None, None
|
|
|
|
|
|
'''
|
|
|
|
if "_cf_client_for_domain" not in text:
|
|
anchor = "def _cloudflare_zone_status(domain: str) -> dict:"
|
|
text = text.replace(anchor, HELPER + anchor)
|
|
|
|
# Replace _cloudflare_zone_status body start
|
|
OLD_STATUS = '''def _cloudflare_zone_status(domain: str) -> dict:
|
|
domain = domain.lower().strip()
|
|
if not settings.cloudflare_api_token:
|
|
return {
|
|
"domain": domain,
|
|
"cloudflare_configured": False,
|
|
"zone_in_account": False,
|
|
"can_apply_mail_records": False,
|
|
"zone_status": None,
|
|
"nameservers": [],
|
|
"managed_zones": [],
|
|
"portal_onboarding_required": True,
|
|
"message": "API Cloudflare não configurada no servidor.",
|
|
}
|
|
cf = CloudflareDNS()
|
|
cf.verify_token()
|
|
managed = cf.list_zone_names()
|
|
zone = cf.get_zone_by_name(domain)
|
|
in_account = zone is not None
|
|
zone_status = zone.get("status") if zone else None
|
|
nameservers = wizard_nameservers(zone) if zone else []
|
|
|
|
return {
|
|
"domain": domain,
|
|
"cloudflare_configured": True,
|
|
"zone_in_account": in_account,
|
|
"can_apply_mail_records": in_account,
|
|
"zone_id": zone["id"] if zone else None,
|
|
"zone_status": zone_status,
|
|
"nameservers": nameservers,
|
|
"managed_zones": managed,
|
|
"portal_onboarding_required": not in_account,
|
|
"message": (
|
|
f"Zona {domain} pronta na Cloudflare Ligbox ({zone_status})."
|
|
if in_account
|
|
else f"Adicione {domain} na Cloudflare Ligbox e altere os NS no registrador."
|
|
),
|
|
}'''
|
|
|
|
NEW_STATUS = '''def _cloudflare_zone_status(domain: str) -> dict:
|
|
domain = domain.lower().strip()
|
|
resolved = resolve_payload(domain)
|
|
match = resolve_ligbox_zone(domain)
|
|
if match:
|
|
cf = match.account.client()
|
|
try:
|
|
cf.verify_token()
|
|
except CloudflareError:
|
|
pass
|
|
zone = match.zone
|
|
zone_status = zone.get("status")
|
|
nameservers = wizard_nameservers(zone)
|
|
return {
|
|
"domain": domain,
|
|
"cloudflare_configured": True,
|
|
"zone_in_account": True,
|
|
"can_apply_mail_records": True,
|
|
"zone_id": match.zone_id,
|
|
"zone_status": zone_status,
|
|
"nameservers": nameservers,
|
|
"ligbox_account_id": match.account.id,
|
|
"ligbox_account_label": f"{match.account.label} ({match.account.admin_email})",
|
|
"dns_mode": match.dns_mode,
|
|
"resolve": resolved,
|
|
"managed_zones": [],
|
|
"portal_onboarding_required": zone_status != "active",
|
|
"message": (
|
|
f"Zona {domain} na conta {match.account.label} ({zone_status})."
|
|
),
|
|
}
|
|
legacy_ok = bool(settings.cloudflare_api_token)
|
|
return {
|
|
"domain": domain,
|
|
"cloudflare_configured": legacy_ok,
|
|
"zone_in_account": False,
|
|
"can_apply_mail_records": False,
|
|
"zone_id": None,
|
|
"zone_status": None,
|
|
"nameservers": [],
|
|
"ligbox_account_id": None,
|
|
"dns_mode": None,
|
|
"resolve": resolved,
|
|
"managed_zones": [],
|
|
"portal_onboarding_required": True,
|
|
"paths_available": resolved.get("paths_available") or ["byo", "external"],
|
|
"message": resolved.get("message")
|
|
or "Domínio fora das contas Ligbox — use BYO Cloudflare ou registrador.",
|
|
}'''
|
|
|
|
if "ligbox_account_id" not in text:
|
|
text = text.replace(OLD_STATUS, NEW_STATUS)
|
|
|
|
RESOLVE_ENDPOINT = '''
|
|
@router.get("/dns/resolve/{domain}")
|
|
def dns_resolve_account(domain: str):
|
|
"""Spec 037 — em qual conta Ligbox (ligit/itecnologys/ibytera) está a zona."""
|
|
domain = domain.lower().strip().rstrip(".")
|
|
try:
|
|
activity_log.info(f"Resolver conta CF Ligbox: {domain}", source="cloudflare")
|
|
return resolve_payload(domain)
|
|
except CloudflareError as e:
|
|
activity_log.error(str(e), source="cloudflare")
|
|
raise HTTPException(502, f"Erro Cloudflare: {e}") from e
|
|
|
|
|
|
@router.post("/dns/cloudflare/connect-custom")
|
|
def connect_custom_cloudflare(body: CloudflareConnectCustomRequest, request: Request):
|
|
"""BYO — token API Cloudflare do cliente (zona já na conta dele)."""
|
|
sid = get_session_from_request(request)
|
|
domain = normalize_domain(body.domain)
|
|
try:
|
|
validate_primary_domain(domain)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=invalid_domain_http_detail()) from e
|
|
cf = CloudflareDNS(token=body.api_token.strip())
|
|
try:
|
|
cf.verify_token()
|
|
zone = cf.get_zone_by_name(domain)
|
|
if not zone:
|
|
raise HTTPException(
|
|
422,
|
|
detail={
|
|
"code": "zone_not_in_customer_account",
|
|
"domain": domain,
|
|
"message": f"Zona {domain} não encontrada com este API Token.",
|
|
},
|
|
)
|
|
if sid:
|
|
dns_byo_vault.save_byo_token(sid, domain, body.api_token.strip(), str(zone["id"]))
|
|
activity_log.ok(f"BYO Cloudflare ligado: {domain}", source="cloudflare")
|
|
return {
|
|
"domain": domain,
|
|
"matched": True,
|
|
"dns_mode": "byo_cloudflare",
|
|
"zone_id": zone.get("id"),
|
|
"zone_status": zone.get("status"),
|
|
"nameservers": wizard_nameservers(zone),
|
|
"message": "Conta Cloudflare do cliente validada. Pode aplicar apontamentos.",
|
|
}
|
|
except CloudflareError as e:
|
|
activity_log.error(f"BYO Cloudflare falhou: {e}", source="cloudflare")
|
|
raise HTTPException(401, f"Token inválido: {e}") from e
|
|
|
|
|
|
'''
|
|
|
|
if "/dns/resolve/" not in text:
|
|
text = text.replace(
|
|
'@router.get("/dns/cloudflare/status/{domain}")',
|
|
RESOLVE_ENDPOINT + '@router.get("/dns/cloudflare/status/{domain}")',
|
|
)
|
|
|
|
# Fix indentation typo in patch if any - the RESOLVE has wrong indent on cf line
|
|
text = text.replace(" cf = CloudflareDNS(token=body.api_token.strip())", " cf = CloudflareDNS(token=body.api_token.strip())")
|
|
|
|
# provision-zone guard
|
|
OLD_PROVISION_START = ''' cf = CloudflareDNS()
|
|
try:
|
|
activity_log.info(f"Criar zona Cloudflare: {domain}", source="cloudflare")
|
|
cf.verify_token()
|
|
result = cf.ensure_zone(domain)'''
|
|
|
|
NEW_PROVISION_START = ''' match = resolve_ligbox_zone(domain)
|
|
if match:
|
|
activity_log.info(
|
|
f"Zona {domain} já na conta {match.account.id} — sem create",
|
|
source="cloudflare",
|
|
)
|
|
payload = _portal_onboarding_payload(domain, zone_created=False)
|
|
payload["zone_id"] = match.zone_id
|
|
payload["ligbox_account_id"] = match.account.id
|
|
payload["dns_mode"] = match.dns_mode
|
|
payload["message"] = (
|
|
f"Domínio {domain} já está na Cloudflare Ligbox ({match.account.label})."
|
|
)
|
|
if nameservers := wizard_nameservers(match.zone):
|
|
payload["nameservers"] = nameservers
|
|
payload["status"]["nameservers"] = nameservers
|
|
return payload
|
|
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail={
|
|
"code": "zone_not_in_ligbox_accounts",
|
|
"domain": domain,
|
|
"message": (
|
|
"Domínio não está nas contas Ligbox (ligit, itecnologys, ibytera). "
|
|
"Use API Token da sua Cloudflare ou apontamentos no registrador."
|
|
),
|
|
"paths_available": ["byo", "external"],
|
|
},
|
|
)
|
|
|
|
cf = CloudflareDNS()
|
|
try:
|
|
activity_log.info(f"Criar zona Cloudflare: {domain}", source="cloudflare")
|
|
cf.verify_token()
|
|
result = cf.ensure_zone(domain)'''
|
|
|
|
if "zone_not_in_ligbox_accounts" not in text:
|
|
text = text.replace(OLD_PROVISION_START, NEW_PROVISION_START)
|
|
|
|
OLD_APPLY = ''' if not settings.cloudflare_api_token:
|
|
raise HTTPException(400, _cloudflare_token_missing_error())
|
|
|
|
cf = CloudflareDNS()
|
|
try:
|
|
activity_log.info(f"Aplicar registos mail na Cloudflare: {domain}", source="cloudflare")
|
|
cf.verify_token()
|
|
except CloudflareError as e:
|
|
activity_log.error(str(e), source="cloudflare")
|
|
raise HTTPException(401, f"Token Cloudflare inválido: {e}") from e
|
|
|
|
zone_id = body.zone_id
|
|
if not zone_id:
|
|
zone = cf.get_zone_by_name(domain)
|
|
if not zone:
|
|
activity_log.error(f"Zona {domain} não está na conta Ligbox", source="cloudflare")
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail={
|
|
"code": "zone_not_in_account",
|
|
"domain": domain,
|
|
"message": f"Zona {domain} ainda não está na Cloudflare Ligbox.",
|
|
"use_portal_onboarding": True,
|
|
},
|
|
)
|
|
zone_id = zone["id"]'''
|
|
|
|
NEW_APPLY = ''' sid = get_session_from_request(request)
|
|
cf, zone_id, dns_mode, ligbox_acct = _cf_client_for_domain(domain, sid)
|
|
if not cf or not zone_id:
|
|
activity_log.error(f"Sem conta CF para {domain}", source="cloudflare")
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail={
|
|
"code": "zone_not_in_account",
|
|
"domain": domain,
|
|
"message": (
|
|
f"Zona {domain} não está nas contas Ligbox nem BYO. "
|
|
"Ligue BYO ou use registrador externo."
|
|
),
|
|
"paths_available": ["byo", "external"],
|
|
},
|
|
)
|
|
try:
|
|
activity_log.info(
|
|
f"Aplicar registos mail ({dns_mode or 'cf'}): {domain}",
|
|
source="cloudflare",
|
|
)
|
|
cf.verify_token()
|
|
except CloudflareError as e:
|
|
activity_log.error(str(e), source="cloudflare")
|
|
raise HTTPException(401, f"Token Cloudflare inválido: {e}") from e
|
|
|
|
if body.zone_id:
|
|
zone_id = body.zone_id'''
|
|
|
|
if "ligbox_acct" not in text:
|
|
text = text.replace(OLD_APPLY, NEW_APPLY)
|
|
|
|
# apply return add dns_mode
|
|
OLD_APPLY_RET = ' return {"domain": domain, "zone_id": zone_id, "applied": applied, "verification": verification}'
|
|
NEW_APPLY_RET = ''' return {
|
|
"domain": domain,
|
|
"zone_id": zone_id,
|
|
"dns_mode": dns_mode,
|
|
"ligbox_account_id": ligbox_acct,
|
|
"applied": applied,
|
|
"verification": verification,
|
|
}'''
|
|
if '"dns_mode": dns_mode' not in text:
|
|
text = text.replace(OLD_APPLY_RET, NEW_APPLY_RET)
|
|
|
|
# dns instructions paths
|
|
OLD_PATHS = ''' "dns_paths": {
|
|
"portal": "Trazer DNS para o portal (Cloudflare conta Ligbox)",
|
|
"external": "Manter DNS no provedor atual (apontamentos manuais)",
|
|
},'''
|
|
NEW_PATHS = ''' "dns_paths": {
|
|
"ligbox": "Cloudflare Ligbox (ligit / itecnologys / ibytera) — apontamentos automáticos",
|
|
"byo": "Sua conta Cloudflare (API Token)",
|
|
"external": "Registrador / DNS externo (apontamentos manuais)",
|
|
},
|
|
"resolve": resolve_payload(domain),'''
|
|
if '"byo"' not in text.split("dns_paths")[1][:200]:
|
|
text = text.replace(OLD_PATHS, NEW_PATHS)
|
|
|
|
ROUTER.write_text(text, encoding="utf-8")
|
|
print("onboarding.py patched OK")
|