# 如何实现自定义域名功能

> 深入拆解自定义域名功能的技术实现原理：DNS 配置与验证、Nginx 动态配置与 SNI、Let's Encrypt 证书自动申请、域名状态管理、完整访问链路设计。通过 Nginx + Node.js 示例代码，帮助理解建站平台、CMS 系统的自定义域名实现机制。

- 发布于：2025-12-18
- 更新于：2025-12-18
- 分类：软件开发
- 标签：系统设计
- 原文：https://chanx.tech/blog/learn-custom-domain

## 一、引言

在建站产品、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 指向 | 后台开始轮询检测 |
| `active` | DNS 验证通过,域名已激活 | DNS 解析指向平台 |
| `cert_pending` | 等待证书申请 | 域名激活后入队 |
| `cert_issued` | 证书已签发,完全可用 | ACME 申请成功 |
| `error` | 验证失败或证书申请失败 | DNS 错误/证书失败 |
| `expired` | 证书过期或 DNS 失效 | 周期检测发现异常 |
| `deleted` | 用户删除或系统回收 | 手动删除/自动回收 |

### 4.2 数据结构

```ts
/** 域名状态 */
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 缓存域名映射关系:

```js
// 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`。

```ts
import { nanoid } from 'nanoid';

const generateCnameKey = () => `cname-${nanoid(16)}`;
```

**DNS 校验实现**:

```ts
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 + 独立证书配置

```nginx
# 域名 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 请求头。

```ts
// 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`)

#### 代码实现示例

```ts
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 等模板引擎渲染配置:

```js
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 监控与回收

后台需要周期性检测域名状态,处理异常域名并回收相关资源(证书、配置、缓存),确保系统一致性和安全性。

#### 异常域名清理

```js
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 解析生效谁获得使用权。

```ts
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;
  }
}
```

**数据库约束**:

```sql
-- Domain 表不设置 domain 字段的唯一约束
-- 而是通过部分唯一索引:只有一个激活状态的域名记录

CREATE UNIQUE INDEX idx_active_domain
ON domains(domain)
WHERE status IN ('active', 'cert_issued');
```

这样可以保证:
- 同一域名可以有多个 pending 记录(不同用户尝试绑定)
- 但只能有一个激活状态的记录(DNS 验证通过的用户)
