#!/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")