一、引言
在建站产品、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 域名状态机
自定义域名从创建到回收,经历多个状态流转。清晰的状态机设计是系统稳定运行的基础。
┌─────────────────────────────────────┐
│ ▼
┌─────────┐ ┌────────────┐ ┌────────┐ ┌───────────────┐
│ pending │───▶│ verifying │───▶│ active │───▶│ cert_pending │
└─────────┘ └────────────┘ └────────┘ └───────────────┘
│ │ │
│ │ ▼
│ │ ┌─────────────┐
│ │ │ cert_issued │
│ │ └─────────────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ error │◀─────│ expired │◀─────│ deleted │
└─────────┘ └─────────┘ └─────────┘
| 状态 | 含义 | 触发条件 |
|---|---|---|
pending | 域名已创建,等待用户配置 DNS | 用户添加域名 |
verifying | 正在验证 DNS 指向 | 后台开始轮询检测 |
active | DNS 验证通过,域名已激活 | DNS 解析指向平台 |
cert_pending | 等待证书申请 | 域名激活后入队 |
cert_issued | 证书已签发,完全可用 | ACME 申请成功 |
error | 验证失败或证书申请失败 | DNS 错误/证书失败 |
expired | 证书过期或 DNS 失效 | 周期检测发现异常 |
deleted | 用户删除或系统回收 | 手动删除/自动回收 |
4.2 数据结构
/** 域名状态 */
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;
}4.3 缓存结构设计
为提高访问性能,使用 Redis 缓存域名映射关系:
// 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
}
}缓存更新策略:
- 域名状态变更时同步更新 Redis
- 设置合理的 TTL,防止脏数据
- 使用 Pub/Sub 同步多节点缓存
五、DNS 配置
用户添加自定义域名后,需要在其 DNS 服务商处配置解析记录,将域名流量指向平台。DNS 配置是连接用户域名与平台服务的桥梁。
三种记录类型:
| 类型 | 用途 | 适用场景 | 示例 |
|---|---|---|---|
| A | 域名 → IP | 裸域名(example.com) | example.com → 1.2.3.4 |
| CNAME | 域名 → 域名 | 子域名(www.example.com) | www → site.platform.com |
| TXT | 文本声明 | 所有权验证、证书校验 | _acme → xxx |
选择:
- 裸域名只能用 A 记录(DNS 协议限制,裸域名不能设置 CNAME)
- 子域名优先用 CNAME,平台 IP 变更时用户无需修改
- TXT 不参与流量路由,仅用于验证和声明
CNAME 目标生成:
平台为每个站点生成独立的 CNAME 目标(如 cname-a3f8x2k9m4n7p1.site.platform.com),需配置泛域名解析 *.site.platform.com → 平台 IP。
import { nanoid } from 'nanoid';
const generateCnameKey = () => `cname-${nanoid(16)}`;DNS 校验实现:
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 }
});
}
}六、访问链路
从用户发起请求到最终渲染页面,自定义域名功能涉及完整的访问链路。本章从流量接入 → 路由分发 → 证书管理 → 配置维护 → 监控回收的视角,拆解各层技术实现。
6.1 入口层
当用户通过自定义域名访问时,流量首先到达 Nginx 网关层。Nginx 通过 SNI(Server Name Indication) 识别请求的域名,为每个域名加载独立的 TLS 证书。
多域名 SNI + 独立证书配置
# 域名 www.example.com
server {
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.com
server {
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;
}
}6.2 应用层
流量经 Nginx 转发到 Node.js 应用层后,需要根据请求的 Host 头确定访问的站点。
注意:用户配置 www.example.com CNAME cname-xxx.site.platform.com 后,访问时 Host 头仍是 www.example.com,CNAME 只影响 DNS 解析过程,不影响 HTTP 请求头。
// 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));
});6.3 证书管理
为保证访问安全,每个自定义域名需要独立的 HTTPS 证书。证书申请是异步流程,不应阻塞域名激活。
异步证书申请与部署流程
- 域名状态
active→ 入队申请证书任务 - 调用 ACME API(如 Let’s Encrypt)申请 TLS 证书
- 保存证书文件到指定目录
- 更新 Nginx 配置引用新证书
- 校验配置(
nginx -t) - 平滑重载(
nginx -s reload)
代码实现示例
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();
});
});
}6.4 动态配置
随着自定义域名数量增长,手动维护 Nginx 配置不可行。需要通过模板化 + 自动化脚本实现配置的动态生成、更新和热加载。
配置模板化
使用 Handlebars 或 Mustache 等模板引擎渲染配置:
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);自动化流程:
- 渲染模板 → 写入
/etc/nginx/conf.d/{{domain}}.conf - 校验语法(
nginx -t)→ 热加载(nginx -s reload)
域名生命周期管理
- 新增域名:生成配置 → 写入文件 → 热加载 Nginx
- 删除域名:删除配置文件 → reload → 清理证书和缓存
高可用方案
- 配置中心:使用 Consul / Etcd 同步多节点 Nginx 配置
- CDN / LB:在边缘层处理 SNI 和证书,减轻源站网关压力
6.5 监控与回收
后台需要周期性检测域名状态,处理异常域名并回收相关资源(证书、配置、缓存),确保系统一致性和安全性。
异常域名清理
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 指向是否正常
- 异常标记:DNS 解析失败或指向错误时标记
error - 资源回收:删除无效域名的配置、证书和缓存,防止资源泄露
完整访问链路总结:
用户请求 → DNS 解析 → Nginx (SNI + 证书) → Node.js (Host 路由) → 站点渲染
↑ ↓
周期检测 ← 资源回收 ← 异常处理 ← 动态配置 ← 证书管理
七、域名冲突处理
同一域名可能被多个用户尝试绑定到不同站点。弱校验模式下,采用 DNS 仲裁:谁的 DNS 解析生效谁获得使用权。
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_domain
ON domains(domain)
WHERE status IN ('active', 'cert_issued');这样可以保证:
- 同一域名可以有多个 pending 记录(不同用户尝试绑定)
- 但只能有一个激活状态的记录(DNS 验证通过的用户)