Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
+880
View File
@@ -0,0 +1,880 @@
from __future__ import annotations
import re
import secrets
import socket
import ssl
import subprocess
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
from app.modules.consultants.models import ConsultantProfile
from app.modules.core.tenancy.models import Branch, Tenant
from app.modules.domain_management.models import DomainMapping
DOMAIN_TYPES: tuple[tuple[str, str], ...] = (
("marketplace", "Marketplace / SaaS platform"),
("audit_firm_domain", "Audit firm custom domain"),
("audit_firm_subdomain", "Audit firm platform subdomain"),
("consultant_custom_domain", "Consultant custom domain"),
("consultant_firm_domain", "Consultant under firm domain"),
("consultant_marketplace_subdomain", "Consultant platform subdomain"),
)
PLATFORM_SUBDOMAIN_BASE_DOMAINS: tuple[str, ...] = ("filingabc.com", "filingabc.local")
RESERVED_SUBDOMAIN_LABELS: set[str] = {"www", "mail", "smtp", "imap", "pop", "api", "admin", "app", "portal", "client", "clients", "staff", "system", "marketplace", "support", "billing"}
DOMAIN_STATUSES: tuple[tuple[str, str], ...] = (
("draft", "Draft"),
("pending_verification", "Pending verification"),
("active", "Active"),
("suspended", "Suspended"),
("inactive", "Inactive"),
)
SSL_MODES: tuple[tuple[str, str], ...] = (
("manual", "Manual / deployment managed"),
("coolify", "Coolify proxy"),
("caddy", "Caddy"),
("traefik", "Traefik"),
("cloudflare", "Cloudflare"),
)
SSL_STATUSES: tuple[tuple[str, str], ...] = (
("not_checked", "Not checked"),
("pending_dns", "Pending DNS/proxy"),
("pending_ssl", "Pending SSL"),
("active", "Active"),
("failed", "Failed"),
("manual", "Manual / external"),
)
_HOST_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$", re.IGNORECASE)
@dataclass(frozen=True)
class DomainSslCheckResult:
ok: bool
status: str
message: str
subject: str | None = None
issuer: str | None = None
not_after_utc: datetime | None = None
@dataclass(frozen=True)
class DomainSslProxySnippet:
provider: str
title: str
body: str
@dataclass(frozen=True)
class DomainMappingPayload:
domain_name: str
domain_type: str
tenant_id: int | None = None
branch_id: int | None = None
consultant_id: int | None = None
parent_tenant_id: int | None = None
is_primary: bool = False
is_verified: bool = False
status: str = "draft"
ssl_mode: str = "manual"
notes: str | None = None
def normalize_domain(domain_name: str) -> str:
value = (domain_name or "").strip().lower()
value = value.removeprefix("http://").removeprefix("https://")
value = value.split("/", 1)[0]
value = value.split(":", 1)[0]
return value.rstrip(".")
def validate_domain_payload(db: Session, payload: DomainMappingPayload, mapping_id: int | None = None) -> list[str]:
errors: list[str] = []
domain_name = normalize_domain(payload.domain_name)
if not domain_name:
errors.append("Domain name is required.")
elif not _HOST_RE.match(domain_name) and domain_name not in {"localhost"}:
errors.append("Enter a valid domain name, for example filingabc.com or arrr.filingabc.com.")
if payload.domain_type not in {code for code, _label in DOMAIN_TYPES}:
errors.append("Invalid domain type selected.")
if payload.status not in {code for code, _label in DOMAIN_STATUSES}:
errors.append("Invalid status selected.")
if payload.ssl_mode not in {code for code, _label in SSL_MODES}:
errors.append("Invalid SSL mode selected.")
existing_stmt = select(DomainMapping).where(DomainMapping.domain_name == domain_name)
if mapping_id:
existing_stmt = existing_stmt.where(DomainMapping.id != mapping_id)
if db.execute(existing_stmt).scalar_one_or_none():
errors.append("This domain is already mapped.")
if payload.tenant_id and not db.get(Tenant, payload.tenant_id):
errors.append("Selected audit firm was not found.")
if payload.parent_tenant_id and not db.get(Tenant, payload.parent_tenant_id):
errors.append("Selected parent audit firm was not found.")
if payload.branch_id and not db.get(Branch, payload.branch_id):
errors.append("Selected branch was not found.")
if payload.consultant_id and not db.get(ConsultantProfile, payload.consultant_id):
errors.append("Selected consultant was not found.")
if payload.domain_type in {"audit_firm_domain", "audit_firm_subdomain"} and not payload.tenant_id:
errors.append("Audit firm domains must be linked to an audit firm.")
if payload.domain_type in {"consultant_custom_domain", "consultant_firm_domain", "consultant_marketplace_subdomain"} and not payload.consultant_id:
errors.append("Consultant domains must be linked to a consultant profile.")
if payload.domain_type == "consultant_firm_domain" and not payload.parent_tenant_id:
errors.append("Consultant firm-domain mappings must have a parent audit firm.")
if payload.domain_type == "audit_firm_subdomain" and domain_name:
parts = domain_name.split(".")
if len(parts) < 3:
errors.append("Audit firm subdomain must be like auditfirm.filingabc.com.")
subdomain_label = parts[0] if parts else ""
if subdomain_label in RESERVED_SUBDOMAIN_LABELS:
errors.append(f"'{subdomain_label}' is a reserved subdomain label. Please use another audit firm code.")
if subdomain_label and not re.match(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", subdomain_label):
errors.append("Subdomain label may contain only lowercase letters, numbers and hyphen, and cannot start/end with hyphen.")
return errors
def make_verification_token() -> str:
return "af-domain-" + secrets.token_urlsafe(24).replace("-", "").replace("_", "")[:36]
def dns_txt_name_for(domain_name: str) -> str:
return f"_audit-firm-verify.{normalize_domain(domain_name)}"
def list_domain_mappings(db: Session, *, q: str = "", status: str = "", domain_type: str = "") -> list[DomainMapping]:
stmt: Select[tuple[DomainMapping]] = select(DomainMapping).order_by(DomainMapping.created_at_utc.desc(), DomainMapping.id.desc())
q = (q or "").strip().lower()
if q:
like = f"%{q}%"
stmt = stmt.where(func.lower(DomainMapping.domain_name).like(like))
if status:
stmt = stmt.where(DomainMapping.status == status)
if domain_type:
stmt = stmt.where(DomainMapping.domain_type == domain_type)
return list(db.execute(stmt).scalars().all())
def create_domain_mapping(db: Session, payload: DomainMappingPayload, *, user_id: int | None) -> DomainMapping:
domain_name = normalize_domain(payload.domain_name)
token = make_verification_token()
mapping = DomainMapping(
domain_name=domain_name,
domain_type=payload.domain_type,
tenant_id=payload.tenant_id,
branch_id=payload.branch_id,
consultant_id=payload.consultant_id,
parent_tenant_id=payload.parent_tenant_id,
is_primary=payload.is_primary,
is_verified=payload.is_verified,
status="active" if payload.is_verified and payload.status == "active" else payload.status,
verification_token=token,
dns_txt_name=dns_txt_name_for(domain_name),
dns_txt_value=token,
ssl_mode=payload.ssl_mode or "manual",
notes=(payload.notes or "").strip() or None,
created_by_user_id=user_id,
updated_by_user_id=user_id,
)
db.add(mapping)
db.commit()
db.refresh(mapping)
return mapping
def update_domain_mapping(db: Session, mapping: DomainMapping, payload: DomainMappingPayload, *, user_id: int | None) -> DomainMapping:
old_domain = mapping.domain_name
mapping.domain_name = normalize_domain(payload.domain_name)
mapping.domain_type = payload.domain_type
mapping.tenant_id = payload.tenant_id
mapping.branch_id = payload.branch_id
mapping.consultant_id = payload.consultant_id
mapping.parent_tenant_id = payload.parent_tenant_id
mapping.is_primary = payload.is_primary
mapping.is_verified = payload.is_verified
mapping.status = payload.status
mapping.ssl_mode = payload.ssl_mode or "manual"
mapping.notes = (payload.notes or "").strip() or None
mapping.updated_by_user_id = user_id
mapping.updated_at_utc = datetime.now(timezone.utc)
if old_domain != mapping.domain_name or not mapping.verification_token:
mapping.verification_token = make_verification_token()
mapping.dns_txt_name = dns_txt_name_for(mapping.domain_name)
mapping.dns_txt_value = mapping.verification_token
mapping.is_verified = False
if mapping.status == "active":
mapping.status = "pending_verification"
db.commit()
db.refresh(mapping)
return mapping
def regenerate_verification_token(db: Session, mapping: DomainMapping, *, user_id: int | None) -> DomainMapping:
mapping.verification_token = make_verification_token()
mapping.dns_txt_name = dns_txt_name_for(mapping.domain_name)
mapping.dns_txt_value = mapping.verification_token
mapping.is_verified = False
if mapping.status == "active":
mapping.status = "pending_verification"
mapping.updated_by_user_id = user_id
mapping.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(mapping)
return mapping
def mark_verified(db: Session, mapping: DomainMapping, *, user_id: int | None) -> DomainMapping:
mapping.is_verified = True
mapping.status = "active"
mapping.updated_by_user_id = user_id
mapping.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(mapping)
return mapping
def slugify_subdomain_label(value: str) -> str:
"""Create a safe subdomain label from a tenant code/name."""
raw = (value or "").strip().lower()
raw = re.sub(r"[^a-z0-9-]+", "-", raw)
raw = re.sub(r"-+", "-", raw).strip("-")
if not raw:
raw = "firm"
if raw in RESERVED_SUBDOMAIN_LABELS:
raw = f"{raw}-firm"
return raw[:63].strip("-") or "firm"
def tenant_subdomain_label(tenant: Tenant) -> str:
code = getattr(tenant, "code", None) or getattr(tenant, "name", None) or f"firm-{tenant.id}"
return slugify_subdomain_label(str(code))
def build_tenant_subdomain(tenant: Tenant, base_domain: str = "filingabc.com") -> str:
base = normalize_domain(base_domain or "filingabc.com")
return f"{tenant_subdomain_label(tenant)}.{base}"
def list_tenant_subdomain_candidates(db: Session, *, base_domain: str = "filingabc.com") -> list[dict]:
"""Return all tenants with their suggested platform subdomain and existing mapping, if any."""
base = normalize_domain(base_domain or "filingabc.com")
tenants = db.execute(select(Tenant).order_by(Tenant.name.asc())).scalars().all()
out: list[dict] = []
for tenant in tenants:
domain_name = build_tenant_subdomain(tenant, base)
mapping = db.execute(select(DomainMapping).where(DomainMapping.domain_name == domain_name)).scalar_one_or_none()
out.append({"tenant": tenant, "domain_name": domain_name, "mapping": mapping})
return out
def create_or_get_tenant_subdomain_mapping(
db: Session,
*,
tenant_id: int,
base_domain: str = "filingabc.com",
branch_id: int | None = None,
mark_verified_active: bool = True,
user_id: int | None = None,
) -> tuple[DomainMapping, bool]:
"""Create auditfirm.filingabc.com mapping for a tenant without duplicating existing rows.
Returns (mapping, created). The platform owner normally verifies wildcard DNS/SSL once,
so mark_verified_active=True is safe for the owned *.filingabc.com style domain.
"""
tenant = db.get(Tenant, int(tenant_id))
if not tenant:
raise ValueError("Selected audit firm was not found.")
domain_name = build_tenant_subdomain(tenant, base_domain)
existing = db.execute(select(DomainMapping).where(DomainMapping.domain_name == domain_name)).scalar_one_or_none()
if existing:
return existing, False
payload = DomainMappingPayload(
domain_name=domain_name,
domain_type="audit_firm_subdomain",
tenant_id=tenant.id,
branch_id=branch_id,
is_primary=False,
is_verified=bool(mark_verified_active),
status="active" if mark_verified_active else "pending_verification",
ssl_mode="coolify",
notes="Auto-created platform tenant subdomain. Ensure wildcard DNS/SSL for *.filingabc.com is configured in deployment.",
)
errors = validate_domain_payload(db, payload)
if errors:
raise ValueError(" ".join(errors))
mapping = create_domain_mapping(db, payload, user_id=user_id)
return mapping, True
def create_or_get_firm_custom_domain_mapping(
db: Session,
*,
tenant_id: int,
domain_name: str = "arrr.associates",
branch_id: int | None = None,
mark_verified_active: bool = False,
user_id: int | None = None,
) -> tuple[DomainMapping, bool]:
"""Create an audit-firm custom domain mapping such as arrr.associates.
This is intentionally separate from tenant subdomains. Custom domains normally
need DNS verification before activation, so mark_verified_active defaults to
False. For a domain owned by the platform owner and already configured in DNS,
an admin may tick mark_verified_active to make it active immediately.
Returns (mapping, created).
"""
tenant = db.get(Tenant, int(tenant_id))
if not tenant:
raise ValueError("Selected audit firm was not found.")
normalized_domain = normalize_domain(domain_name or "arrr.associates")
existing = db.execute(select(DomainMapping).where(DomainMapping.domain_name == normalized_domain)).scalar_one_or_none()
if existing:
return existing, False
payload = DomainMappingPayload(
domain_name=normalized_domain,
domain_type="audit_firm_domain",
tenant_id=tenant.id,
branch_id=branch_id,
is_primary=True,
is_verified=bool(mark_verified_active),
status="active" if mark_verified_active else "pending_verification",
ssl_mode="coolify",
notes=(
"Custom audit firm domain created from Phase 7T.6. "
"Ensure DNS points to the ERP server and SSL/proxy configuration is completed before live use."
),
)
errors = validate_domain_payload(db, payload)
if errors:
raise ValueError(" ".join(errors))
mapping = create_domain_mapping(db, payload, user_id=user_id)
return mapping, True
def consultant_subdomain_label(consultant: ConsultantProfile) -> str:
"""Create a safe domain label for consultant profile domains."""
raw = (getattr(consultant, "firm_name", None) or getattr(consultant, "contact_person", None) or f"consultant-{consultant.id}")
return slugify_subdomain_label(str(raw))
def build_consultant_platform_subdomain(consultant: ConsultantProfile, base_domain: str = "filingabc.com") -> str:
base = normalize_domain(base_domain or "filingabc.com")
return f"{consultant_subdomain_label(consultant)}.{base}"
def build_consultant_firm_domain(consultant: ConsultantProfile, firm_base_domain: str = "arrr.accountant") -> str:
base = normalize_domain(firm_base_domain or "arrr.accountant")
return f"{consultant_subdomain_label(consultant)}.{base}"
def list_consultant_domain_candidates(
db: Session,
*,
base_domain: str = "filingabc.com",
firm_base_domain: str = "arrr.accountant",
) -> list[dict]:
"""Return consultants with suggested marketplace and firm-linked profile domains."""
consultants = db.execute(select(ConsultantProfile).order_by(ConsultantProfile.contact_person.asc())).scalars().all()
out: list[dict] = []
for consultant in consultants:
platform_domain = build_consultant_platform_subdomain(consultant, base_domain)
firm_domain = build_consultant_firm_domain(consultant, firm_base_domain)
platform_mapping = db.execute(select(DomainMapping).where(DomainMapping.domain_name == platform_domain)).scalar_one_or_none()
firm_mapping = db.execute(select(DomainMapping).where(DomainMapping.domain_name == firm_domain)).scalar_one_or_none()
out.append({
"consultant": consultant,
"platform_domain": platform_domain,
"platform_mapping": platform_mapping,
"firm_domain": firm_domain,
"firm_mapping": firm_mapping,
})
return out
def create_or_get_consultant_domain_mapping(
db: Session,
*,
consultant_id: int,
domain_type: str = "consultant_marketplace_subdomain",
domain_name: str | None = None,
base_domain: str = "filingabc.com",
firm_base_domain: str = "arrr.accountant",
parent_tenant_id: int | None = None,
mark_verified_active: bool = False,
user_id: int | None = None,
) -> tuple[DomainMapping, bool]:
"""Create consultant profile domain mapping without duplicating existing rows.
Supported examples:
consultant_marketplace_subdomain -> consultant.filingabc.com
consultant_firm_domain -> consultant.arrr.accountant
consultant_custom_domain -> abc.accountants
"""
consultant = db.get(ConsultantProfile, int(consultant_id))
if not consultant:
raise ValueError("Selected consultant profile was not found.")
if domain_type == "consultant_marketplace_subdomain":
normalized_domain = normalize_domain(domain_name or build_consultant_platform_subdomain(consultant, base_domain))
resolved_parent_tenant_id = parent_tenant_id
ssl_mode = "coolify"
default_notes = "Auto-created consultant marketplace profile subdomain. Ensure wildcard DNS/SSL for the platform base domain is configured."
default_verified = True
elif domain_type == "consultant_firm_domain":
normalized_domain = normalize_domain(domain_name or build_consultant_firm_domain(consultant, firm_base_domain))
resolved_parent_tenant_id = parent_tenant_id or consultant.tenant_id
ssl_mode = "coolify"
default_notes = "Consultant domain under an audit firm brand. Ensure DNS/SSL for the firm consultant domain is configured."
default_verified = False
elif domain_type == "consultant_custom_domain":
if not domain_name:
raise ValueError("Custom consultant domain is required.")
normalized_domain = normalize_domain(domain_name)
resolved_parent_tenant_id = parent_tenant_id
ssl_mode = "coolify"
default_notes = "Consultant custom domain. Verify DNS ownership before activation."
default_verified = False
else:
raise ValueError("Invalid consultant domain type selected.")
existing = db.execute(select(DomainMapping).where(DomainMapping.domain_name == normalized_domain)).scalar_one_or_none()
if existing:
return existing, False
verified = bool(mark_verified_active or default_verified)
payload = DomainMappingPayload(
domain_name=normalized_domain,
domain_type=domain_type,
tenant_id=consultant.tenant_id,
branch_id=consultant.branch_id,
consultant_id=consultant.id,
parent_tenant_id=resolved_parent_tenant_id,
is_primary=False,
is_verified=verified,
status="active" if verified else "pending_verification",
ssl_mode=ssl_mode,
notes=default_notes,
)
errors = validate_domain_payload(db, payload)
if errors:
raise ValueError(" ".join(errors))
mapping = create_domain_mapping(db, payload, user_id=user_id)
return mapping, True
def reference_data(db: Session) -> dict:
return {
"tenants": db.execute(select(Tenant).order_by(Tenant.name.asc())).scalars().all(),
"branches": db.execute(select(Branch).order_by(Branch.name.asc())).scalars().all(),
"consultants": db.execute(select(ConsultantProfile).order_by(ConsultantProfile.contact_person.asc())).scalars().all(),
"domain_types": DOMAIN_TYPES,
"statuses": DOMAIN_STATUSES,
"ssl_modes": SSL_MODES,
"ssl_statuses": SSL_STATUSES,
}
@dataclass(frozen=True)
class DomainResolution:
"""Resolved runtime domain context used by Phase 7T.2 middleware."""
is_resolved: bool
host: str
mapping_id: int | None = None
domain_name: str | None = None
domain_type: str | None = None
tenant_id: int | None = None
tenant_code: str | None = None
branch_id: int | None = None
branch_code: str | None = None
consultant_id: int | None = None
parent_tenant_id: int | None = None
is_verified: bool = False
status: str | None = None
def normalize_request_host(host_header: str | None) -> str:
"""Normalise the HTTP Host / X-Forwarded-Host value for lookup.
Handles values such as:
localhost:8000
arrr.associates
arrr.associates:443
arrr.associates, proxy-host
"""
value = (host_header or "").strip().lower()
if not value:
return ""
value = value.split(",", 1)[0].strip()
value = value.removeprefix("http://").removeprefix("https://")
value = value.split("/", 1)[0]
if value.startswith("[") and "]" in value:
# IPv6 literal; keep without brackets/port for safety.
value = value.split("]", 1)[0].lstrip("[")
else:
value = value.split(":", 1)[0]
return value.rstrip(".")
def resolve_domain_context(db: Session, host: str) -> DomainResolution:
"""Resolve an incoming host to an active domain mapping.
Phase 7T.2 intentionally resolves only exact domain mappings. Wildcard/platform
subdomain inference comes later in Phase 7T.5, and custom-domain DNS checks come
in Phase 7T.8.
"""
normalized = normalize_domain(host)
if not normalized:
return DomainResolution(is_resolved=False, host="")
stmt = (
select(DomainMapping, Tenant.code, Branch.code)
.outerjoin(Tenant, Tenant.id == DomainMapping.tenant_id)
.outerjoin(Branch, Branch.id == DomainMapping.branch_id)
.where(DomainMapping.domain_name == normalized)
.where(DomainMapping.status == "active")
.where(DomainMapping.is_verified.is_(True))
.limit(1)
)
row = db.execute(stmt).first()
if not row:
return DomainResolution(is_resolved=False, host=normalized)
mapping, tenant_code, branch_code = row
return DomainResolution(
is_resolved=True,
host=normalized,
mapping_id=int(mapping.id),
domain_name=mapping.domain_name,
domain_type=mapping.domain_type,
tenant_id=mapping.tenant_id,
tenant_code=tenant_code,
branch_id=mapping.branch_id,
branch_code=branch_code,
consultant_id=mapping.consultant_id,
parent_tenant_id=mapping.parent_tenant_id,
is_verified=bool(mapping.is_verified),
status=mapping.status,
)
@dataclass(frozen=True)
class DomainDnsVerificationResult:
ok: bool
message: str
txt_name: str
expected_value: str
found_values: tuple[str, ...] = ()
def _normalise_txt_value(value: str) -> str:
value = (value or "").strip()
# TXT records may be returned with quotes and split chunks.
if value.startswith('"') and value.endswith('"') and len(value) >= 2:
value = value[1:-1]
value = value.replace('" "', '').replace('"', '').strip()
return value
def _resolve_txt_with_dnspython(txt_name: str) -> tuple[list[str], str | None]:
try:
import dns.resolver # type: ignore
except Exception:
return [], "dnspython is not installed"
try:
answers = dns.resolver.resolve(txt_name, "TXT")
values: list[str] = []
for answer in answers:
try:
chunks = [part.decode("utf-8", errors="ignore") if isinstance(part, bytes) else str(part) for part in answer.strings]
values.append("".join(chunks))
except Exception:
values.append(str(answer).strip())
return values, None
except Exception as exc:
return [], str(exc)
def _resolve_txt_with_nslookup(txt_name: str) -> tuple[list[str], str | None]:
try:
completed = subprocess.run(
["nslookup", "-type=TXT", txt_name],
capture_output=True,
text=True,
timeout=12,
check=False,
)
except FileNotFoundError:
return [], "nslookup command is not available on this machine"
except Exception as exc:
return [], str(exc)
output = "\n".join([completed.stdout or "", completed.stderr or ""])
if completed.returncode != 0 and not output.strip():
return [], "DNS lookup failed"
values: list[str] = []
for line in output.splitlines():
line = line.strip()
if not line:
continue
# Windows/Linux nslookup generally prints TXT values inside quotes.
quoted = re.findall(r'"([^"]+)"', line)
if quoted:
values.append("".join(quoted))
continue
if "text =" in line.lower():
values.append(line.split("=", 1)[1].strip())
if not values and output.strip():
# Keep a short diagnostic without storing full command noise.
return [], output.strip().splitlines()[-1][:240]
return values, None
def lookup_dns_txt_values(txt_name: str) -> tuple[list[str], str | None]:
"""Return TXT records for a DNS name using dnspython when available, else nslookup.
No new dependency is required. On Windows, nslookup is normally available by default.
"""
txt_name = normalize_domain(txt_name)
if not txt_name:
return [], "TXT name is empty"
values, error = _resolve_txt_with_dnspython(txt_name)
if values:
return [_normalise_txt_value(v) for v in values], None
ns_values, ns_error = _resolve_txt_with_nslookup(txt_name)
if ns_values:
return [_normalise_txt_value(v) for v in ns_values], None
return [], ns_error or error or "No TXT record found"
def verify_domain_dns_txt(db: Session, mapping: DomainMapping, *, user_id: int | None) -> DomainDnsVerificationResult:
"""Verify a custom/platform domain using its DNS TXT token.
If the expected TXT value is found, the domain is marked verified and active. If not,
the mapping remains unverified and is moved to pending_verification unless suspended.
"""
txt_name = mapping.dns_txt_name or dns_txt_name_for(mapping.domain_name)
expected = mapping.dns_txt_value or mapping.verification_token or ""
if not expected:
mapping.verification_token = make_verification_token()
mapping.dns_txt_name = txt_name
mapping.dns_txt_value = mapping.verification_token
expected = mapping.dns_txt_value
db.commit()
db.refresh(mapping)
found_values, error = lookup_dns_txt_values(txt_name)
found_normalised = tuple(_normalise_txt_value(v) for v in found_values if _normalise_txt_value(v))
expected_normalised = _normalise_txt_value(expected)
matched = any(v == expected_normalised or expected_normalised in v for v in found_normalised)
now = datetime.now(timezone.utc)
if matched:
mapping.is_verified = True
mapping.status = "active"
mapping.updated_by_user_id = user_id
mapping.updated_at_utc = now
db.commit()
db.refresh(mapping)
return DomainDnsVerificationResult(
ok=True,
message="DNS TXT verification successful. Domain is now active.",
txt_name=txt_name,
expected_value=expected_normalised,
found_values=found_normalised,
)
if mapping.status not in {"suspended", "inactive"}:
mapping.status = "pending_verification"
mapping.is_verified = False
mapping.updated_by_user_id = user_id
mapping.updated_at_utc = now
db.commit()
db.refresh(mapping)
msg = "DNS TXT record not found or value does not match."
if error:
msg = f"{msg} DNS response: {error}"
return DomainDnsVerificationResult(
ok=False,
message=msg,
txt_name=txt_name,
expected_value=expected_normalised,
found_values=found_normalised,
)
def list_domains_requiring_verification(db: Session) -> list[DomainMapping]:
stmt = (
select(DomainMapping)
.where(DomainMapping.is_verified.is_(False))
.where(DomainMapping.status.in_(["draft", "pending_verification"]))
.order_by(DomainMapping.updated_at_utc.desc(), DomainMapping.id.desc())
)
return list(db.execute(stmt).scalars().all())
def verify_all_pending_domains(db: Session, *, user_id: int | None, limit: int = 25) -> list[tuple[DomainMapping, DomainDnsVerificationResult]]:
results: list[tuple[DomainMapping, DomainDnsVerificationResult]] = []
for mapping in list_domains_requiring_verification(db)[: max(1, min(limit, 100))]:
result = verify_domain_dns_txt(db, mapping, user_id=user_id)
results.append((mapping, result))
return results
def _name_tuple_to_text(name_tuple) -> str | None:
try:
parts: list[str] = []
for section in name_tuple or []:
for key, value in section:
if value:
parts.append(f"{key}={value}")
return ", ".join(parts) if parts else None
except Exception:
return None
def _parse_ssl_not_after(value: str | None) -> datetime | None:
if not value:
return None
try:
dt = parsedate_to_datetime(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
except Exception:
return None
def check_domain_ssl_certificate(db: Session, mapping: DomainMapping, *, user_id: int | None = None, timeout: float = 5.0) -> DomainSslCheckResult:
"""Check whether the domain currently serves a valid TLS certificate on 443.
This does not issue certificates. Certificate issuance is handled by Coolify/Caddy/
Traefik/Cloudflare/Certbot. This function records readiness/status inside ERP.
"""
domain = normalize_domain(mapping.domain_name)
now = datetime.now(timezone.utc)
if not domain or domain == "localhost":
mapping.ssl_status = "manual"
mapping.ssl_last_checked_at_utc = now
mapping.ssl_last_error = "Localhost does not require public SSL certificate checking."
db.commit()
return DomainSslCheckResult(False, "manual", mapping.ssl_last_error)
if not mapping.is_verified or mapping.status != "active":
mapping.ssl_status = "pending_dns"
mapping.ssl_last_checked_at_utc = now
mapping.ssl_last_error = "Domain must be verified and active before SSL check."
db.commit()
return DomainSslCheckResult(False, "pending_dns", mapping.ssl_last_error)
try:
context = ssl.create_default_context()
with socket.create_connection((domain, 443), timeout=timeout) as sock:
with context.wrap_socket(sock, server_hostname=domain) as ssock:
cert = ssock.getpeercert()
subject = _name_tuple_to_text(cert.get("subject"))
issuer = _name_tuple_to_text(cert.get("issuer"))
not_after = _parse_ssl_not_after(cert.get("notAfter"))
mapping.ssl_status = "active"
mapping.ssl_last_checked_at_utc = now
mapping.ssl_not_after_utc = not_after
mapping.ssl_subject = subject
mapping.ssl_issuer = issuer
mapping.ssl_last_error = None
db.commit()
db.refresh(mapping)
return DomainSslCheckResult(True, "active", "SSL certificate is active and trusted.", subject, issuer, not_after)
except Exception as exc:
mapping.ssl_status = "failed"
mapping.ssl_last_checked_at_utc = now
mapping.ssl_last_error = str(exc)[:1000]
db.commit()
db.refresh(mapping)
return DomainSslCheckResult(False, "failed", f"SSL check failed: {mapping.ssl_last_error}")
def mark_ssl_managed(db: Session, mapping: DomainMapping, *, provider: str, user_id: int | None = None) -> DomainMapping:
provider = (provider or mapping.ssl_mode or "manual").strip().lower()
valid = {code for code, _label in SSL_MODES}
if provider not in valid:
provider = "manual"
mapping.ssl_provider = provider
mapping.ssl_mode = provider
mapping.ssl_status = "manual" if provider in {"manual", "cloudflare"} else "pending_ssl"
mapping.updated_by_user_id = user_id
mapping.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(mapping)
return mapping
def list_ssl_domains(db: Session) -> list[DomainMapping]:
stmt = (
select(DomainMapping)
.where(DomainMapping.status.in_(["active", "pending_verification", "draft"]))
.order_by(DomainMapping.domain_type.asc(), DomainMapping.domain_name.asc())
)
return list(db.execute(stmt).scalars().all())
def build_ssl_proxy_snippets(mapping: DomainMapping, *, app_upstream: str = "http://127.0.0.1:8000") -> list[DomainSslProxySnippet]:
domain = normalize_domain(mapping.domain_name)
if not domain or domain == "localhost":
return []
caddy = f"""{domain} {{
encode gzip
reverse_proxy {app_upstream}
}}"""
nginx = f"""server {{
listen 80;
server_name {domain};
location / {{
proxy_pass {app_upstream};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}}
}}
# After DNS points to this server:
# certbot --nginx -d {domain}"""
coolify = f"""Coolify setup checklist for {domain}
1. Open the Audit ERP application in Coolify.
2. Add domain: https://{domain}
3. Ensure DNS A/CNAME points to the Coolify server.
4. Enable Force HTTPS after certificate is issued.
5. Keep Host header forwarding enabled.
6. In ERP, verify DNS TXT and then run SSL check."""
return [
DomainSslProxySnippet("coolify", "Coolify domain setup", coolify),
DomainSslProxySnippet("caddy", "Caddy automatic HTTPS snippet", caddy),
DomainSslProxySnippet("nginx", "Nginx + Certbot snippet", nginx),
]