new-site/scripts/workers/services/porkbun.py
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 06:54:22 -05:00

260 lines
8.7 KiB
Python

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