Files
replypier/backend/app/services/domain_verification.py
T
WangShen007 54b949b392 chore(brand): rename the product to ReplyPier
The upstream project name is gone from code, assets, docs and build
output: brand strings, domains (replypier.site and its app/api/docs/help
subdomains), env prefixes, image and network names, the widget's public
JS API (window.ReplyPier, replypierId/BaseUrl/Config/Token), CSS tokens
(--rp-*), file and directory names, and the WordPress plugin package.

- widget loader is now webclient/replypier.min.js; both bundles rebuilt
  from source (backend/assets/widget.{js,css}, frontend/public/webclient)
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and config.dev.js
  rewritten rather than string-swapped; the demo config no longer ships
  the previous vendor's Firebase/Razorpay/Google keys
- new migration replypier_ai_model_001 adds REPLYPIER to the managed
  AIModelType and rewrites pre-rename rows
- local compose project, database, role and volume renamed; LOCAL_DEPLOY
  documents carrying the old local data over
2026-10-26 00:51:03 +08:00

189 lines
7.3 KiB
Python

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Custom-domain verification for the public help center.
The customer proves control of the domain with a TXT record
(_replypier.<domain> = rp-verify=<token>) and routes traffic with a CNAME
to HELP_CENTER_CNAME_TARGET (A/AAAA records matching HELP_CENTER_TARGET_IPS
are accepted for providers that flatten CNAMEs). Both must pass before the
host dispatcher will ever serve the domain. SSL turns active once an HTTPS
probe against /healthz succeeds with a valid certificate.
"""
import os
import secrets
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.logger import get_logger
from app.models.help_center import HelpCenterSettings, SSLStatus
from app.repositories.help_center import HelpCenterRepository
logger = get_logger(__name__)
DNS_LIFETIME_SECONDS = 5.0
SSL_PROBE_TIMEOUT_SECONDS = 10.0
TXT_RECORD_PREFIX = "_replypier"
# Public resolvers for verification lookups. The container's own resolver is
# Docker's embedded DNS (127.0.0.11) for service discovery — it negatively
# caches names that didn't exist when first queried (so a TXT added *after* the
# customer set up the domain keeps returning NXDOMAIN). Query public DNS
# directly, and let self-hosters override if their network blocks these.
_PUBLIC_DNS_RESOLVERS = [
ip.strip()
for ip in os.getenv("DOMAIN_VERIFY_DNS_RESOLVERS", "1.1.1.1,8.8.8.8,9.9.9.9").split(",")
if ip.strip()
]
def generate_verification_token() -> str:
return secrets.token_hex(16)
def txt_record_host(domain: str) -> str:
return f"{TXT_RECORD_PREFIX}.{domain}"
def expected_txt_value(row: HelpCenterSettings) -> str:
return f"rp-verify={row.domain_verification_token}"
def _resolver():
import dns.resolver
resolver = dns.resolver.Resolver()
if _PUBLIC_DNS_RESOLVERS:
resolver.nameservers = _PUBLIC_DNS_RESOLVERS
resolver.lifetime = DNS_LIFETIME_SECONDS
return resolver
def check_txt_record(row: HelpCenterSettings) -> bool:
"""True when the verification TXT record is present."""
import dns.resolver
expected = expected_txt_value(row)
try:
answers = _resolver().resolve(txt_record_host(row.custom_domain), "TXT")
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers,
dns.resolver.LifetimeTimeout):
return False
for record in answers:
value = b"".join(record.strings).decode("utf-8", errors="replace")
if value.strip() == expected:
return True
return False
def check_cname_record(domain: str) -> bool:
"""True when the domain points at us: CNAME to the target, or (for
CNAME-flattening providers) A/AAAA records matching the target IPs."""
import dns.resolver
target = settings.HELP_CENTER_CNAME_TARGET.rstrip(".").lower()
try:
answers = _resolver().resolve(domain, "CNAME")
return any(str(record.target).rstrip(".").lower() == target for record in answers)
except (dns.resolver.NXDOMAIN, dns.resolver.NoNameservers, dns.resolver.LifetimeTimeout):
return False
except dns.resolver.NoAnswer:
pass # no CNAME — fall through to flattened A/AAAA comparison
if not settings.HELP_CENTER_TARGET_IPS:
return False
resolved = set()
for rtype in ("A", "AAAA"):
try:
resolved.update(str(r) for r in _resolver().resolve(domain, rtype))
except Exception:
continue
return bool(resolved) and resolved <= settings.HELP_CENTER_TARGET_IPS
def probe_ssl(domain: str) -> bool:
"""HTTPS reachability + certificate validation, resolving via PUBLIC DNS.
The container's own resolver (Docker embedded DNS) negatively caches a
custom domain that didn't exist before its cert was provisioned — so httpx
(which uses the system resolver) would keep failing. Resolve the A record
ourselves and connect to that IP with SNI = the domain, which also verifies
the served cert is actually issued for this domain."""
import socket
import ssl
try:
ip = str(_resolver().resolve(domain, "A")[0])
except Exception as e:
logger.info(f"SSL probe DNS for {domain} not ready: {e}")
return False
try:
ctx = ssl.create_default_context()
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
with socket.create_connection((ip, 443), timeout=SSL_PROBE_TIMEOUT_SECONDS) as sock:
with ctx.wrap_socket(sock, server_hostname=domain) as tls:
tls.sendall(
f"GET /healthz HTTP/1.1\r\nHost: {domain}\r\nConnection: close\r\n\r\n".encode()
)
status_line = tls.recv(128).split(b"\r\n", 1)[0]
return b" 200 " in status_line
except (OSError, ssl.SSLError) as e:
logger.info(f"SSL probe for {domain} not ready: {e}")
return False
def set_custom_domain(db: Session, row: HelpCenterSettings, domain: str) -> HelpCenterSettings:
"""Claim a (normalized) domain: fresh token, verification state reset."""
base_suffix = f".{settings.HELP_CENTER_BASE_DOMAIN}"
if domain == settings.HELP_CENTER_BASE_DOMAIN or domain.endswith(base_suffix):
raise ValueError(f"{settings.HELP_CENTER_BASE_DOMAIN} subdomains are assigned automatically and cannot be claimed.")
row.custom_domain = domain
row.domain_verification_token = generate_verification_token()
row.txt_record_verified = False
row.cname_record_verified = False
row.ssl_status = SSLStatus.NONE
row.domain_verified_at = None
return HelpCenterRepository(db).update(row)
def clear_custom_domain(db: Session, row: HelpCenterSettings) -> HelpCenterSettings:
row.custom_domain = None
row.domain_verification_token = None
row.txt_record_verified = False
row.cname_record_verified = False
row.ssl_status = SSLStatus.NONE
row.domain_verified_at = None
return HelpCenterRepository(db).update(row)
def verify_custom_domain(db: Session, row: HelpCenterSettings) -> HelpCenterSettings:
"""Run both DNS checks (and the SSL probe once verified) and persist the
outcome. Blocking (DNS/HTTP I/O) — callers run it off the event loop."""
if not row.custom_domain:
raise ValueError("No custom domain configured.")
row.txt_record_verified = check_txt_record(row)
row.cname_record_verified = check_cname_record(row.custom_domain)
if row.domain_verified:
if not row.domain_verified_at:
row.domain_verified_at = datetime.now(timezone.utc)
if row.ssl_status != SSLStatus.ACTIVE.value:
row.ssl_status = (
SSLStatus.ACTIVE if probe_ssl(row.custom_domain) else SSLStatus.PENDING
)
else:
row.domain_verified_at = None
row.ssl_status = SSLStatus.NONE
return HelpCenterRepository(db).update(row)