如何实现自定义域名功能

Chanx 3171 字 11 分钟阅读

一、引言

在建站产品、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 配置。

三、流程拆解

  1. 用户在控制台添加域名
  2. 平台生成域名记录,状态为 pending
  3. 用户配置 DNS(A / CNAME 或 TXT)
  4. 后台轮询验证 DNS 指向
  5. DNS 生效 → 域名状态置为 active
  6. 后台异步申请 HTTPS 证书
  7. 动态生成或更新 Nginx 配置
  8. 流量进入入口层(Nginx / CDN)
  9. 应用层根据 Host 映射站点
  10. 周期检测 DNS → 异常下线或回收

四、状态机与数据结构

4.1 域名状态机

自定义域名从创建到回收,经历多个状态流转。清晰的状态机设计是系统稳定运行的基础。

                    ┌─────────────────────────────────────┐
                    │                                     ▼
┌─────────┐    ┌────────────┐    ┌────────┐    ┌───────────────┐
│ pending │───▶│ verifying  │───▶│ active │───▶│ cert_pending  │
└─────────┘    └────────────┘    └────────┘    └───────────────┘
                    │                 │                 │
                    │                 │                 ▼
                    │                 │         ┌─────────────┐
                    │                 │         │ cert_issued │
                    │                 │         └─────────────┘
                    │                 │                 │
                    ▼                 ▼                 ▼
               ┌─────────┐      ┌─────────┐      ┌─────────┐
               │  error  │◀─────│ expired │◀─────│ deleted │
               └─────────┘      └─────────┘      └─────────┘
状态含义触发条件
pending域名已创建,等待用户配置 DNS用户添加域名
verifying正在验证 DNS 指向后台开始轮询检测
activeDNS 验证通过,域名已激活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 证书。证书申请是异步流程,不应阻塞域名激活。

异步证书申请与部署流程

  1. 域名状态 active → 入队申请证书任务
  2. 调用 ACME API(如 Let’s Encrypt)申请 TLS 证书
  3. 保存证书文件到指定目录
  4. 更新 Nginx 配置引用新证书
  5. 校验配置(nginx -t)
  6. 平滑重载(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 验证通过的用户)
按下 K 进行搜索