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>
260 lines
8.7 KiB
Python
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 ""
|