first commit
This commit is contained in:
commit
cce9a8c9ba
73 changed files with 8665 additions and 0 deletions
380
backend/app/services/domain_provisioning.py
Normal file
380
backend/app/services/domain_provisioning.py
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import ipaddress
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from pwdlib import PasswordHash
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import DomainDnsRecord, MailDomain, SmtpCredential
|
||||
|
||||
password_hasher = PasswordHash.recommended()
|
||||
domain_pattern = re.compile(
|
||||
r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+"
|
||||
r"[a-z]{2,63}$"
|
||||
)
|
||||
|
||||
|
||||
def normalize_domain(value: str) -> str:
|
||||
return value.strip().lower().removeprefix("@")
|
||||
|
||||
|
||||
def validate_domain(value: str) -> str:
|
||||
domain = normalize_domain(value)
|
||||
if not domain_pattern.fullmatch(domain):
|
||||
raise ValueError("Enter a valid domain name, such as example.com.")
|
||||
return domain
|
||||
|
||||
|
||||
def generate_dkim_keys() -> tuple[str, str]:
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
private_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode()
|
||||
public_pem = (
|
||||
private_key.public_key()
|
||||
.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
.decode()
|
||||
)
|
||||
public_key = "".join(
|
||||
line
|
||||
for line in public_pem.splitlines()
|
||||
if not line.startswith("-----")
|
||||
)
|
||||
return private_pem, public_key
|
||||
|
||||
|
||||
def namecheap_host(host: str, domain_name: str) -> str:
|
||||
"""Strip the zone suffix so records match Namecheap's Host field."""
|
||||
if host in {"@", "*"}:
|
||||
return host
|
||||
suffix = f".{domain_name}"
|
||||
if host.endswith(suffix):
|
||||
return host[: -len(suffix)]
|
||||
return host
|
||||
|
||||
|
||||
def spf_ip_mechanism(mail_server_ip: str | None) -> str | None:
|
||||
if not mail_server_ip or mail_server_ip == "YOUR_MAIL_SERVER_IP":
|
||||
return None
|
||||
try:
|
||||
address = ipaddress.ip_address(mail_server_ip.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return f"ip6:{address}" if address.version == 6 else f"ip4:{address}"
|
||||
|
||||
|
||||
def build_spf_value(mail_server_ip: str | None = None) -> str:
|
||||
parts = ["v=spf1", "mx", "a"]
|
||||
ip_mechanism = spf_ip_mechanism(mail_server_ip)
|
||||
if ip_mechanism:
|
||||
parts.append(ip_mechanism)
|
||||
parts.append("~all")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def build_shared_mx_spf_value(platform_spf_include: str) -> str:
|
||||
return f"v=spf1 include:{platform_spf_include} ~all"
|
||||
|
||||
|
||||
def build_company_dns_records(
|
||||
domain_name: str,
|
||||
shared_mail_host: str,
|
||||
dkim_selector: str,
|
||||
dkim_public_key: str,
|
||||
platform_spf_include: str,
|
||||
) -> list[dict]:
|
||||
"""Zoho-style records: shared MX host, SPF include, per-domain DKIM/DMARC."""
|
||||
dkim_value = f"v=DKIM1; k=rsa; p={dkim_public_key}"
|
||||
dmarc_value = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
|
||||
return [
|
||||
{
|
||||
"record_type": "MX",
|
||||
"host": namecheap_host("@", domain_name),
|
||||
"value": shared_mail_host,
|
||||
"priority": 10,
|
||||
"purpose": "mx",
|
||||
"ttl": 3600,
|
||||
},
|
||||
{
|
||||
"record_type": "TXT",
|
||||
"host": namecheap_host("@", domain_name),
|
||||
"value": build_shared_mx_spf_value(platform_spf_include),
|
||||
"purpose": "spf",
|
||||
"ttl": 3600,
|
||||
},
|
||||
{
|
||||
"record_type": "TXT",
|
||||
"host": namecheap_host(f"{dkim_selector}._domainkey", domain_name),
|
||||
"value": dkim_value,
|
||||
"purpose": "dkim",
|
||||
"ttl": 3600,
|
||||
},
|
||||
{
|
||||
"record_type": "TXT",
|
||||
"host": namecheap_host("_dmarc", domain_name),
|
||||
"value": dmarc_value,
|
||||
"purpose": "dmarc",
|
||||
"ttl": 3600,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def build_dns_records(
|
||||
domain_name: str,
|
||||
mail_host: str,
|
||||
dkim_selector: str,
|
||||
dkim_public_key: str,
|
||||
mail_server_ip: str | None = None,
|
||||
) -> list[dict]:
|
||||
dkim_value = f"v=DKIM1; k=rsa; p={dkim_public_key}"
|
||||
dmarc_value = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
|
||||
mail_subdomain = mail_host.removesuffix(f".{domain_name}")
|
||||
|
||||
return [
|
||||
{
|
||||
"record_type": "MX",
|
||||
"host": namecheap_host("@", domain_name),
|
||||
"value": mail_host,
|
||||
"priority": 10,
|
||||
"purpose": "mx",
|
||||
"ttl": 3600,
|
||||
},
|
||||
{
|
||||
"record_type": "A",
|
||||
"host": namecheap_host(mail_subdomain, domain_name),
|
||||
"value": mail_server_ip or "YOUR_MAIL_SERVER_IP",
|
||||
"purpose": "mail_host",
|
||||
"ttl": 3600,
|
||||
},
|
||||
{
|
||||
"record_type": "TXT",
|
||||
"host": namecheap_host("@", domain_name),
|
||||
"value": build_spf_value(mail_server_ip),
|
||||
"purpose": "spf",
|
||||
"ttl": 3600,
|
||||
},
|
||||
{
|
||||
"record_type": "TXT",
|
||||
"host": namecheap_host(f"{dkim_selector}._domainkey", domain_name),
|
||||
"value": dkim_value,
|
||||
"purpose": "dkim",
|
||||
"ttl": 3600,
|
||||
},
|
||||
{
|
||||
"record_type": "TXT",
|
||||
"host": namecheap_host("_dmarc", domain_name),
|
||||
"value": dmarc_value,
|
||||
"purpose": "dmarc",
|
||||
"ttl": 3600,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def build_deployment_dns_records(
|
||||
*,
|
||||
domain_name: str,
|
||||
mail_server_ip: str,
|
||||
dkim_selector: str,
|
||||
dkim_public_key: str | None,
|
||||
app_public_host: str | None = None,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Split records into inbound delivery vs outbound authentication."""
|
||||
mail_host = f"mail.{domain_name}"
|
||||
dkim_key = dkim_public_key or (
|
||||
"REGISTER_DOMAIN_TO_GENERATE_DKIM_PUBLIC_KEY"
|
||||
)
|
||||
records = build_dns_records(
|
||||
domain_name,
|
||||
mail_host,
|
||||
dkim_selector,
|
||||
dkim_key,
|
||||
mail_server_ip=mail_server_ip,
|
||||
)
|
||||
by_purpose = {record["purpose"]: record for record in records}
|
||||
inbound = [by_purpose["mx"], by_purpose["mail_host"]]
|
||||
if app_public_host:
|
||||
inbound.append(
|
||||
{
|
||||
"record_type": "A",
|
||||
"host": namecheap_host(app_public_host, domain_name),
|
||||
"value": mail_server_ip,
|
||||
"purpose": "app_host",
|
||||
"ttl": 3600,
|
||||
}
|
||||
)
|
||||
outbound = [by_purpose["spf"], by_purpose["dkim"], by_purpose["dmarc"]]
|
||||
ip_mechanism = spf_ip_mechanism(mail_server_ip)
|
||||
if ip_mechanism:
|
||||
outbound.insert(
|
||||
0,
|
||||
{
|
||||
"record_type": "TXT",
|
||||
"host": namecheap_host("_spf", domain_name),
|
||||
"value": f"v=spf1 {ip_mechanism} ~all",
|
||||
"purpose": "platform_spf",
|
||||
"ttl": 3600,
|
||||
},
|
||||
)
|
||||
return {"inbound": inbound, "outbound": outbound}
|
||||
|
||||
|
||||
def generate_smtp_username(domain_name: str) -> str:
|
||||
slug = domain_name.replace(".", "-")
|
||||
token = secrets.token_hex(4)
|
||||
return f"smtp-{slug}-{token}"
|
||||
|
||||
|
||||
def generate_smtp_password(length: int = 24) -> str:
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def record_for_db(record: dict) -> dict:
|
||||
allowed = {"record_type", "host", "value", "purpose", "ttl"}
|
||||
return {key: record[key] for key in allowed if key in record}
|
||||
|
||||
|
||||
async def get_domain_by_name(
|
||||
session: AsyncSession,
|
||||
domain_name: str,
|
||||
) -> MailDomain | None:
|
||||
return await session.scalar(
|
||||
select(MailDomain).where(MailDomain.domain_name == domain_name)
|
||||
)
|
||||
|
||||
|
||||
async def provision_mail_domain(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
domain_name: str,
|
||||
domain_type: str,
|
||||
account_id=None,
|
||||
) -> MailDomain:
|
||||
settings = get_settings()
|
||||
normalized = validate_domain(domain_name)
|
||||
existing = await get_domain_by_name(session, normalized)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
mail_host = f"mail.{normalized}"
|
||||
dkim_selector = settings.dkim_selector
|
||||
private_key, public_key = generate_dkim_keys()
|
||||
if domain_type == "company":
|
||||
mail_host = settings.smtp_public_host
|
||||
dns_records = build_company_dns_records(
|
||||
normalized,
|
||||
settings.smtp_public_host,
|
||||
dkim_selector,
|
||||
public_key,
|
||||
settings.platform_spf_include,
|
||||
)
|
||||
else:
|
||||
dns_records = build_dns_records(
|
||||
normalized,
|
||||
mail_host,
|
||||
dkim_selector,
|
||||
public_key,
|
||||
)
|
||||
domain = MailDomain(
|
||||
domain_name=normalized,
|
||||
domain_type=domain_type,
|
||||
account_id=account_id,
|
||||
mail_host=mail_host,
|
||||
dkim_selector=dkim_selector,
|
||||
dkim_private_key=private_key,
|
||||
dkim_public_key=public_key,
|
||||
status="pending_dns",
|
||||
)
|
||||
session.add(domain)
|
||||
await session.flush()
|
||||
|
||||
for record in dns_records:
|
||||
session.add(DomainDnsRecord(domain_id=domain.id, **record_for_db(record)))
|
||||
|
||||
await session.flush()
|
||||
return domain
|
||||
|
||||
|
||||
def register_relay_domain(domain_name: str) -> None:
|
||||
"""Append a verified company domain for Postfix inbound relay."""
|
||||
settings = get_settings()
|
||||
normalized = validate_domain(domain_name)
|
||||
path = Path(settings.relay_domains_file)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = {
|
||||
line.strip().lower()
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
} if path.exists() else set()
|
||||
if normalized in existing:
|
||||
return
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"{normalized}\n")
|
||||
|
||||
|
||||
async def activate_company_domain(session: AsyncSession, domain: MailDomain) -> MailDomain:
|
||||
domain.status = "active"
|
||||
register_relay_domain(domain.domain_name)
|
||||
await session.flush()
|
||||
return domain
|
||||
|
||||
|
||||
async def get_account_company_domain(
|
||||
session: AsyncSession,
|
||||
account_id,
|
||||
company_domain_name: str | None,
|
||||
) -> MailDomain | None:
|
||||
if not company_domain_name:
|
||||
return None
|
||||
return await session.scalar(
|
||||
select(MailDomain)
|
||||
.options(selectinload(MailDomain.dns_records))
|
||||
.where(
|
||||
MailDomain.domain_name == company_domain_name,
|
||||
MailDomain.account_id == account_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def create_smtp_credential(
|
||||
session: AsyncSession,
|
||||
domain: MailDomain,
|
||||
*,
|
||||
label: str,
|
||||
) -> tuple[SmtpCredential, str]:
|
||||
username = generate_smtp_username(domain.domain_name)
|
||||
plain_password = generate_smtp_password()
|
||||
credential = SmtpCredential(
|
||||
domain_id=domain.id,
|
||||
username=username,
|
||||
password_hash=password_hasher.hash(plain_password),
|
||||
label=label,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(credential)
|
||||
await session.flush()
|
||||
return credential, plain_password
|
||||
|
||||
|
||||
async def ensure_platform_domain(session: AsyncSession) -> MailDomain:
|
||||
settings = get_settings()
|
||||
domain = await get_domain_by_name(session, settings.platform_domain)
|
||||
if domain:
|
||||
return domain
|
||||
return await provision_mail_domain(
|
||||
session,
|
||||
domain_name=settings.platform_domain,
|
||||
domain_type="platform",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue