一、引言
在建站产品、CMS 或低代码平台中,自定义域名功能涉及完整的访问链路管理。
域名不仅是一个配置字段,更是浏览器请求、DNS、网关、应用和 HTTPS 证书的交汇点。
本文将从工程视角拆解自定义域名功能。
二、概念
- 自定义域名:用户为建站产品中的站点配置的专属域名,用于访问站点内容。
- 流量路由(A / CNAME):DNS 记录类型,用于将用户浏览器请求解析到平台入口。
- A 记录:将域名指向平台的 IP 地址。
- CNAME 记录:将域名指向平台的另一个域名。
- 所有权验证 / 配置声明(TXT):DNS TXT 记录,用于校验用户对域名的控制权,或作为证书 DNS-01 校验的一部分。
- Nginx / 网关层:处理 TLS 终端、SNI、Host 路由和流量转发到应用层。
- SNI(Server Name Indication):TLS 扩展,用于根据客户端请求的域名选择对应证书。
- Node.js 应用层:处理业务逻辑和 Host 映射,根据请求 Host 决定访问的站点内容。
- HTTPS / TLS 证书:为每个域名提供独立加密证书,用于浏览器验证和安全传输。
- 动态配置维护:通过模板、脚本或配置中心生成、更新和热加载 Nginx 配置,实现多域名自动化管理。
- 周期检测与资源回收:后台定时检查域名状态,处理异常或迁移域名,回收对应证书和 Nginx 配置。
三、流程拆解
- 用户在控制台添加域名
- 平台生成域名记录,状态为
pending - 用户配置 DNS(A / CNAME 或 TXT)
- 后台轮询验证 DNS 指向
- DNS 生效 → 域名状态置为
active - 后台异步申请 HTTPS 证书
- 动态生成或更新 Nginx 配置
- 流量进入入口层(Nginx / CDN)
- 应用层根据 Host 映射站点
- 周期检测 DNS → 异常下线或回收
四、状态机与数据结构
4.1 域名状态机
自定义域名从创建到回收,经历多个状态流转。清晰的状态机设计是系统稳定运行的基础。
4.2 数据结构
4.3 缓存结构设计
为提高访问性能,使用 Redis 缓存域名映射关系:
缓存更新策略:
- 域名状态变更时同步更新 Redis
- 设置合理的 TTL,防止脏数据
- 使用 Pub/Sub 同步多节点缓存
五、DNS 配置
用户添加自定义域名后,需要在其 DNS 服务商处配置解析记录,将域名流量指向平台。DNS 配置是连接用户域名与平台服务的桥梁。
三种记录类型:
选择:
- 裸域名只能用 A 记录(DNS 协议限制,裸域名不能设置 CNAME)
- 子域名优先用 CNAME,平台 IP 变更时用户无需修改
- TXT 不参与流量路由,仅用于验证和声明
CNAME 目标生成:
平台为每个站点生成独立的 CNAME 目标(如 cname-a3f8x2k9m4n7p1.site.platform.com),需配置泛域名解析 *.site.platform.com → 平台 IP。
DNS 校验实现:
六、访问链路
从用户发起请求到最终渲染页面,自定义域名功能涉及完整的访问链路。本章从流量接入 → 路由分发 → 证书管理 → 配置维护 → 监控回收的视角,拆解各层技术实现。
6.1 入口层
当用户通过自定义域名访问时,流量首先到达 Nginx 网关层。Nginx 通过 SNI(Server Name Indication) 识别请求的域名,为每个域名加载独立的 TLS 证书。
多域名 SNI + 独立证书配置
6.2 应用层
流量经 Nginx 转发到 Node.js 应用层后,需要根据请求的 Host 头确定访问的站点。
注意
www.example.com CNAME cname-xxx.site.platform.com 后,访问时 Host 头仍是 www.example.com,CNAME 只影响 DNS 解析过程,不影响 HTTP 请求头。
6.3 证书管理
为保证访问安全,每个自定义域名需要独立的 HTTPS 证书。证书申请是异步流程,不应阻塞域名激活。
异步证书申请与部署流程
- 域名状态
active→ 入队申请证书任务 - 调用 ACME API(如 Let's Encrypt)申请 TLS 证书
- 保存证书文件到指定目录
- 更新 Nginx 配置引用新证书
- 校验配置(
nginx -t) - 平滑重载(
nginx -s reload)
代码实现示例
6.4 动态配置
随着自定义域名数量增长,手动维护 Nginx 配置不可行。需要通过模板化 + 自动化脚本实现配置的动态生成、更新和热加载。
配置模板化
使用 Handlebars 或 Mustache 等模板引擎渲染配置:
自动化流程:
- 渲染模板 → 写入
/etc/nginx/conf.d/{{domain}}.conf - 校验语法(
nginx -t)→ 热加载(nginx -s reload)
域名生命周期管理
- 新增域名 → 写入文件 → 热加载 Nginx
- 删除域名 → reload → 清理证书和缓存
高可用方案
- 配置中心 Consul / Etcd 同步多节点 Nginx 配置
- CDN / LB SNI 和证书,减轻源站网关压力
6.5 监控与回收
后台需要周期性检测域名状态,处理异常域名并回收相关资源(证书、配置、缓存),确保系统一致性和安全性。
异常域名清理
监控策略:
- 周期检测 DNS 指向是否正常
- 异常标记 解析失败或指向错误时标记
error - 资源回收、证书和缓存,防止资源泄露
完整访问链路总结:
七、域名冲突处理
同一域名可能被多个用户尝试绑定到不同站点。弱校验模式下,采用 DNS 仲裁
DNS 解析生效谁获得使用权。数据库约束:
这样可以保证:
- 同一域名可以有多个 pending 记录(不同用户尝试绑定)
- 但只能有一个激活状态的记录(DNS 验证通过的用户)
┌─────────────────────────────────────┐ │ ▼┌─────────┐ ┌────────────┐ ┌────────┐ ┌───────────────┐│ pending │───▶│ verifying │───▶│ active │───▶│ cert_pending │└─────────┘ └────────────┘ └────────┘ └───────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────┐ │ │ │ cert_issued │ │ │ └─────────────┘ │ │ │ ▼ ▼ ▼ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ error │◀─────│ expired │◀─────│ deleted │ └─────────┘ └─────────┘ └─────────┘/** 域名状态 */type DomainStatus = | 'pending' // 等待配置 | 'verifying' // 验证中 | 'active' // 已激活 | 'cert_pending' // 等待证书 | 'cert_issued' // 证书已签发 | 'error' // 错误 | 'expired' // 已过期 | 'deleted'; // 已删除
/** 站点记录 */interface Site { id: string; cnameKey: string; // 随机生成的 CNAME 标识,如 cname-a3f8x2k9m4n7p1 // ...其他字段}
/** 域名记录 */interface Domain { id: string; siteId: string; // 关联站点 domain: string; // 用户自定义域名 status: DomainStatus; dnsType?: 'A' | 'CNAME'; verifiedAt?: Date; errorMessage?: string; retryCount: number; createdAt: Date; updatedAt: Date;}
/** 证书记录 */interface Certificate { id: string; domainId: string; domain: string; // 冗余,便于查询 certPath: string; // 证书路径 keyPath: string; // 私钥路径 issuer: string; // 颁发机构 issuedAt: Date; // 签发时间 expiresAt: Date; // 过期时间 autoRenew: boolean; // 自动续期 lastRenewedAt?: Date; createdAt: Date;}
/** 操作日志(可选) */interface DomainLog { id: string; domainId: string; action: 'create' | 'verify' | 'activate' | 'cert_issue' | 'cert_renew' | 'error' | 'recover' | 'delete'; fromStatus?: DomainStatus; toStatus?: DomainStatus; message?: string; operator: string; // 'system' | userId createdAt: Date;}// Key 设计`domain:${domain}` → { siteId, status, certIssued }`site:${siteId}:domains` → Set<domain> // 站点下所有域名`cert:renew:queue` → SortedSet<domain, expiresAt> // 续期队列
// 示例数据{ "domain:www.example.com": { "siteId": "site_abc123", "status": "cert_issued", "certIssued": true }}import { nanoid } from 'nanoid';
const generateCnameKey = () => `cname-${nanoid(16)}`;import dns from 'dns/promises';
const PLATFORM_CNAME = 'site.platform.com';const PLATFORM_IPS = ['1.2.3.4', '5.6.7.8'];
/** 弱校验:检查域名是否指向平台 */export async function verifyDomain(domain: string): Promise<boolean> { // 优先检查 CNAME try { const cnames = await dns.resolveCname(domain); if (cnames.some(c => c.endsWith(PLATFORM_CNAME))) return true; } catch {}
// 兜底检查 A 记录 try { const ips = await dns.resolve4(domain); if (ips.some(ip => PLATFORM_IPS.includes(ip))) return true; } catch {}
return false;}
/** 周期轮询更新状态 */async function pollDomains() { const domains = await db.domains.findMany({ where: { status: { in: ['pending', 'verifying'] } } });
for (const d of domains) { const ok = await verifyDomain(d.domain); await db.domains.update({ where: { id: d.id }, data: { status: ok ? 'active' : d.status } }); }}# 域名 www.example.comserver { listen 80; server_name www.example.com;
location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }}
server { listen 443 ssl; server_name www.example.com;
ssl_certificate /etc/nginx/certs/www.example.com.crt; ssl_certificate_key /etc/nginx/certs/www.example.com.key;
location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }}
# 域名 blog.example.comserver { listen 80; server_name blog.example.com;
location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }}
server { listen 443 ssl; server_name blog.example.com;
ssl_certificate /etc/nginx/certs/blog.example.com.crt; ssl_certificate_key /etc/nginx/certs/blog.example.com.key;
location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }}// ACME HTTP-01 验证路由app.get('/.well-known/acme-challenge/:token', async (req, res) => { const { token } = req.params; const host = req.headers.host; if (!host) return res.status(400).send('Missing host header');
// 从缓存读取该域名的 challenge const challenge = await cache.get(`acme:${host}:${token}`); if (!challenge) return res.status(404).send('Not found');
res.type('text/plain').send(challenge);});
async function getSiteIdByHost(host: string): Promise<string | null> { // 1. 先查缓存 const cached = await cache.get(`domain:${host}`); if (cached) return JSON.parse(cached).siteId;
// 2. 缓存未命中,查数据库 const domain = await db.domains.findUnique({ where: { domain: host, status: 'cert_issued' } }); if (!domain) return null;
// 3. 回填缓存 await cache.set(`domain:${host}`, JSON.stringify({ siteId: domain.siteId }), 'EX', 3600); return domain.siteId;}
app.use(async (req, res, next) => { const host = req.headers.host; if (!host) return res.status(400).send('Missing host header');
const siteId = await getSiteIdByHost(host); if (!siteId) return res.status(404).send('Site not found'); req.siteId = siteId; next();});
// 根据 siteId 渲染站点内容app.get('*', async (req, res) => { const siteId = req.siteId; const content = await db.sites.findOne({ where: { id: siteId } }); res.send(renderTemplate(content));});import acme from 'acme-client';import fs from 'fs';import { exec } from 'child_process';
async function issueCertificate(domain: string) { const client = new acme.Client({ directoryUrl: acme.directory.letsencrypt.production }); const [key, csr] = await client.createCsr({ commonName: domain });
const cert = await client.auto({ csr, email: 'admin@platform.com', termsOfServiceAgreed: true, challengeCreateFn: async (authz, challenge, keyAuthorization) => { // HTTP-01: 将验证内容写入缓存 if (challenge.type === 'http-01') { await cache.set( `acme:${domain}:${challenge.token}`, keyAuthorization, 'EX', 600 // 10分钟过期 ); } }, challengeRemoveFn: async (authz, challenge, keyAuthorization) => { // 移除缓存 if (challenge.type === 'http-01') { await cache.del(`acme:${domain}:${challenge.token}`); } }, });
await fs.promises.writeFile(`/etc/nginx/certs/${domain}.crt`, cert.cert); await fs.promises.writeFile(`/etc/nginx/certs/${domain}.key`, key.toString());
// 校验 Nginx 配置 await execAsync('nginx -t');
// 校验通过后重载 await execAsync('nginx -s reload');}
function execAsync(cmd: string): Promise<void> { return new Promise((resolve, reject) => { exec(cmd, (error) => { if (error) reject(error); else resolve(); }); });}import Handlebars from 'handlebars';
const template = Handlebars.compile(`server { listen 443 ssl; server_name {{domain}};
ssl_certificate /etc/nginx/certs/{{domain}}.crt; ssl_certificate_key /etc/nginx/certs/{{domain}}.key;
location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; }}`);
// 渲染配置const config = template({ domain: 'www.example.com' });await fs.promises.writeFile(`/etc/nginx/conf.d/www.example.com.conf`, config);async function cleanExpiredDomains() { const domains = await db.domains.findMany({ where: { status: 'error' } });
for (const d of domains) { try { // 使用异步方法删除文件 await fs.promises.unlink(`/etc/nginx/conf.d/${d.domain}.conf`); await fs.promises.unlink(`/etc/nginx/certs/${d.domain}.crt`); await fs.promises.unlink(`/etc/nginx/certs/${d.domain}.key`);
console.log(`Cleaned up domain: ${d.domain}`); } catch (error) { // 文件可能已被删除或不存在,记录错误但继续处理其他域名 console.error(`Failed to clean domain ${d.domain}:`, error.message); } }
// 校验并重载 Nginx try { await execAsync('nginx -t'); await execAsync('nginx -s reload'); } catch (error) { console.error('Nginx reload failed:', error); throw error; }}用户请求 → DNS 解析 → Nginx (SNI + 证书) → Node.js (Host 路由) → 站点渲染 ↑ ↓ 周期检测 ← 资源回收 ← 异常处理 ← 动态配置 ← 证书管理async function addDomain(userId: string, siteId: string, domain: string) { // 1. 检查是否已被其他站点绑定且激活 const existing = await db.domains.findFirst({ where: { domain, status: { in: ['active', 'cert_issued'] } } });
if (existing && existing.siteId !== siteId) { // 已被其他站点激活,检查当前 DNS 是否指向该用户的 CNAME const site = await db.sites.findUnique({ where: { id: siteId } }); const isPointingToMe = await checkDnsPointsTo(domain, site.cnameKey);
if (!isPointingToMe) { throw new Error('该域名已被其他站点使用'); }
// DNS 指向当前用户,标记原记录为过期,创建新记录 await db.domains.update({ where: { id: existing.id }, data: { status: 'expired' } }); }
// 2. 创建新的域名记录 return db.domains.create({ data: { siteId, domain, status: 'pending' } });}
async function checkDnsPointsTo(domain: string, cnameKey: string): Promise<boolean> { try { const cnames = await dns.resolveCname(domain); return cnames.some(c => c.startsWith(cnameKey)); } catch { return false; }}-- Domain 表不设置 domain 字段的唯一约束-- 而是通过部分唯一索引:只有一个激活状态的域名记录
CREATE UNIQUE INDEX idx_active_domainON domains(domain)WHERE status IN ('active', 'cert_issued');