""" Porkbun .ca domain registration service. Production client for registering .ca domains via the Porkbun REST API. Called by the CRTC pipeline to register the client's .ca domain. WHOIS Contact Setup (Porkbun account-level — set once in dashboard): Technical contact: Performance West Inc. (filings@performancewest.net) Billing contact: Performance West Inc. (admin@performancewest.net) Admin/Registrant: Set per-domain to the customer's order email NOTE: Porkbun v3 API does not support per-domain contact customization. All domains inherit the account-level default contacts. The account should be configured in the Porkbun dashboard with PW as tech/billing and a generic admin contact. Post-registration, the customer email is set via the CIRA .ca registrant update process (manual for now). WHOIS Privacy: Porkbun includes free WHOIS privacy for most TLDs. For .ca domains, CIRA (the .ca registry) requires the registrant organization name to be publicly visible regardless of privacy settings. Individual contact details (name, address, phone, email) can be hidden. Environment variables: PORKBUN_API_KEY pk1_... PORKBUN_SECRET_KEY sk1_... Usage: from scripts.workers.services.porkbun import register_ca_domain result = register_ca_domain("testcompany.ca", order_number="CA-2026-XXXXX") """ from __future__ import annotations import logging import os import re import socket from typing import Optional import requests LOG = logging.getLogger("workers.porkbun") API_BASE = "https://api.porkbun.com/api/json/v3" _api_key = os.environ.get("PORKBUN_API_KEY", "") _secret_key = os.environ.get("PORKBUN_SECRET_KEY", "") def _auth(**extra) -> dict: return {"apikey": _api_key, "secretapikey": _secret_key, **extra} def _post(path: str, **extra) -> dict: r = requests.post(f"{API_BASE}/{path}", json=_auth(**extra), timeout=30) r.raise_for_status() return r.json() def ping() -> bool: """Verify API credentials.""" try: data = _post("ping") ok = data.get("status") == "SUCCESS" LOG.info("Porkbun ping: %s (IP: %s)", "OK" if ok else "FAIL", data.get("yourIp")) return ok except Exception as e: LOG.error("Porkbun ping failed: %s", e) return False def check_availability_whois(domain: str) -> bool: """Check .ca domain availability via CIRA WHOIS (Porkbun API doesn't support this).""" try: s = socket.create_connection(("whois.cira.ca", 43), timeout=10) s.sendall(f"{domain}\r\n".encode()) response = b"" while True: chunk = s.recv(4096) if not chunk: break response += chunk s.close() text = response.decode("utf-8", errors="ignore") available = "Not found" in text LOG.info("WHOIS %s: %s", domain, "AVAILABLE" if available else "TAKEN") return available except Exception as e: LOG.error("WHOIS check failed for %s: %s", domain, e) return False def register_domain( domain: str, nameservers: Optional[list[str]] = None, years: int = 1, ) -> dict: """ Register a .ca domain via Porkbun. Args: domain: Full domain name (e.g. "mycompany.ca") nameservers: Custom NS records. Defaults to HestiaCP NS. years: Registration period (default 1 year) Returns: {"success": bool, "domain": str, "error": str} """ # Default to HestiaCP nameservers if not nameservers: nameservers = [ "ns0.cp.carrierone.com", "ns1.he.net", "ns2.he.net", "ns3.he.net", "ns4.he.net", "ns5.he.net", ] try: body = _auth(domain=domain, years=years) if nameservers: body["ns"] = nameservers r = requests.post( f"{API_BASE}/domain/register/{domain}", json=body, timeout=60, ) data = r.json() if data.get("status") == "SUCCESS": LOG.info("Porkbun: registered %s for %d year(s)", domain, years) return {"success": True, "domain": domain} else: msg = data.get("message", str(data)) LOG.error("Porkbun registration failed for %s: %s", domain, msg) return {"success": False, "domain": domain, "error": msg} except Exception as e: LOG.error("Porkbun registration error for %s: %s", domain, e) return {"success": False, "domain": domain, "error": str(e)} def set_whois_privacy(domain: str, enabled: bool = True) -> dict: """ Enable or disable WHOIS privacy for a domain. For .ca domains: CIRA requires the registrant organization name to remain publicly visible. WHOIS privacy only hides individual contact details (name, address, phone, email). Porkbun v3 API doesn't have a dedicated WHOIS privacy endpoint, but domains registered under our account get free WHOIS privacy by default. This function is a placeholder for when the API supports it, or for toggling via the Porkbun dashboard. """ LOG.info("WHOIS privacy for %s: %s", domain, "enabled" if enabled else "disabled") # Porkbun includes free WHOIS privacy by default. # No API endpoint to toggle it — must be done in Porkbun dashboard if needed. # For .ca: CIRA publishes org name regardless. return {"success": True, "domain": domain, "privacy": enabled} def register_ca_domain( domain: str, order_number: str = "", skip_whois: bool = False, domain_privacy: bool = True, customer_email: str = "", ) -> dict: """ Full .ca domain registration workflow: 1. WHOIS availability check 2. Porkbun registration with HestiaCP nameservers 3. Set WHOIS privacy preference 4. Return result WHOIS contact roles: Technical + Billing: Performance West (set at Porkbun account level) Admin/Registrant: Customer email (set post-registration via CIRA if needed) Args: domain: The .ca domain to register order_number: For logging context skip_whois: Skip availability check (if already verified) domain_privacy: Whether to enable WHOIS privacy (default True) customer_email: Customer's email for CIRA registrant contact Returns: {"success": bool, "domain": str, "error": str} """ LOG.info("[%s] Registering .ca domain: %s (privacy=%s, customer=%s)", order_number, domain, domain_privacy, customer_email or "account default") # Validate it's a .ca domain if not domain.endswith(".ca"): return {"success": False, "domain": domain, "error": "Not a .ca domain"} # Clean the domain domain = domain.lower().strip() # Step 1: Check availability if not skip_whois: if not check_availability_whois(domain): return {"success": False, "domain": domain, "error": "Domain is not available"} # Step 2: Register result = register_domain(domain) if result["success"]: # Step 3: Set WHOIS privacy preference set_whois_privacy(domain, enabled=domain_privacy) # Step 4: Log customer email for CIRA registrant update # NOTE: Porkbun API doesn't support per-domain contact changes. # The registrant contact defaults to the Porkbun account owner (PW). # For .ca domains, the customer should be the admin contact — this # requires a manual CIRA registrant transfer or a support ticket to # Porkbun to update the admin contact to the customer's email. if customer_email: LOG.info( "[%s] Customer email for CIRA admin contact: %s " "(manual Porkbun support ticket needed to set per-domain admin)", order_number, customer_email, ) return result def generate_domain_name(company_name: str, bc_number: str = "") -> str: """ Generate a .ca domain name from a company name. For numbered companies: uses a slugified version of the trade name or falls back to "pw-{bc_number}.ca" For named companies: slugifies the company name, removes legal endings. """ if not company_name or company_name.lower().startswith("numbered"): if bc_number: return f"pw-{bc_number.lower()}.ca" return "" # Remove legal endings name = re.sub( r"\s+(ltd\.?|inc\.?|corp\.?|llc\.?|co\.?|limited|incorporated|corporation)\s*$", "", company_name, flags=re.IGNORECASE, ).strip() # Slugify slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") # Truncate to 63 chars (DNS label limit) if len(slug) > 59: # leave room for ".ca" slug = slug[:59].rstrip("-") return f"{slug}.ca" if slug else ""