为域名申请 HTTPS 证书时,HTTP-01 往往是最省事的方式。但在一些环境中,DNS-01 更合适:

  • 需要申请通配符证书;
  • 域名尚未指向业务服务器;
  • Web 服务不方便临时修改;
  • 证书申请节点与业务节点彼此分离;
  • 网络入口不允许开放额外验证端口。

DNS-01 的基本原理很简单:证书机构要求申请者在指定域名下添加一条 TXT 记录,查询到正确值后,即可确认申请者拥有该域名的 DNS 控制权。

Certbot 已经能够在命令行中完成这一流程,但命令行操作并不适合所有用户。尤其是在申请根域名、多个子域名和通配符证书时,TXT 记录的管理、验证顺序、失败恢复和证书下载都会迅速变得复杂。

因此,一个围绕 DNS-01 构建的 Web 工具,价值并不在于“把命令搬到网页上”,而在于把整个异步验证过程变成一个安全、清晰、可恢复的状态机。

本文已对域名、主机、目录、端口、服务名、接口路径、文件名和部署拓扑进行抽象处理,不包含实际生产环境中的可定位信息。


一、工具的目标是什么

这个工具面向无法或不愿直接操作 Certbot 命令行的用户。

用户只需要完成几件事:

  1. 输入主域名和联系邮箱;
  2. 添加需要申请的子域名或通配符;
  3. 根据页面提示添加 TXT 记录;
  4. 等待 DNS 查询通过;
  5. 确认证书申请;
  6. 下载证书和私钥。

系统负责:

  • 生成证书申请任务;
  • 调用 Certbot;
  • 接收 DNS challenge;
  • 展示 TXT 记录;
  • 查询权威 DNS 和公共解析器;
  • 保存任务状态;
  • 控制 Certbot 继续执行;
  • 生成一次性下载文件;
  • 自动清理过期任务。

二、为什么不能只是给 Certbot 套一个表单

最简单的实现似乎是:

网页提交域名
→ 后端运行 Certbot
→ 返回结果

这种设计很快就会遇到问题。

Certbot 的 DNS-01 流程需要等待用户手动修改 DNS。这个过程可能持续几分钟,也可能更久。如果 HTTP 请求一直保持连接:

  • Web 进程会被长期占用;
  • 代理可能超时;
  • 用户刷新页面后状态丢失;
  • 多个申请会互相影响;
  • 失败任务容易留下僵尸进程或锁。

正确的设计应该是:

网页创建任务
→ 后端立即返回任务状态
→ Certbot 在受控后台进程中运行
→ 页面周期性查询任务状态
→ 用户按步骤完成 DNS 操作

也就是说,Web 请求只负责触发和查询,不能承担整个证书申请生命周期。


三、整体架构

该工具采用常见的多层 Web 架构:

浏览器
  ↓ HTTPS
边缘反向代理
  ↓ 内部 Web 服务器
应用服务器
  ↓
WSGI 服务
  ↓
Flask 应用
  ↓
Certbot

应用进程只监听本机回环地址,不直接暴露给局域网或公网。

外部入口使用现有网站下的一个应用子路径,而不是单独建立新域名。这样可以复用既有的 HTTPS 入口、访问控制和反向代理体系。

真实部署中,外部路径、内部端口、配置文件和服务名称都应视为敏感运维信息,不应出现在公开文章、截图、错误页面或前端源码中。


四、公开访问不等于没有安全边界

这个工具允许公众访问,不要求注册账户。

但“无登录”绝不等于“无身份隔离”。

DNS-01 本身确实能阻止攻击者为不受其控制的域名签发证书,因为对方无法写入正确的 TXT 记录。但是攻击者仍然可以:

  • 批量创建无效任务;
  • 占用唯一的 Certbot 执行槽位;
  • 消耗 CPU、内存和磁盘;
  • 触发证书机构的频率限制;
  • 枚举任务编号;
  • 查看其他用户的 challenge;
  • 尝试下载其他用户生成的私钥;
  • 使用恶意输入攻击命令调用逻辑;
  • 利用失败任务永久锁住系统。

因此,系统采用了:

公开页面
+ 匿名安全会话
+ 任务所有权
+ 请求限速
+ 全局并发限制

五、匿名任务所有权

用户首次访问页面时,服务端会为浏览器建立一个匿名会话。

每个任务会绑定:

  • 随机任务标识;
  • 匿名会话;
  • 所有权校验信息;
  • 创建时间;
  • 更新时间;
  • 当前状态;
  • 过期时间。

只有创建任务的会话可以:

  • 查看任务状态;
  • 查看 TXT challenge;
  • 发起 DNS 查询;
  • 允许当前 challenge 继续;
  • 申请最终证书;
  • 下载证书;
  • 关闭失败任务。

即使其他人知道任务标识,也不能访问任务详情。

任务标识不能使用自增数字、时间戳、IP 地址或短随机字符串。所有权凭证也不应以明文形式存储,服务端只保存其安全摘要。


六、Cookie 与 CSRF

匿名会话 Cookie 应至少具备:

Secure
HttpOnly
SameSite=Strict
限定在应用路径内

限定 Cookie 路径可以避免同一域名下的其他应用意外接收该 Cookie。

所有会改变任务状态的请求都必须经过 CSRF 验证,例如:

  • 创建任务;
  • 查询并确认 DNS;
  • 继续下一条 challenge;
  • 确认签发;
  • 取消任务;
  • 关闭失败任务。

CSRF 防护不能只依赖前端 JavaScript。真正的验证必须在服务端完成。


七、子路径部署的隐藏复杂度

应用运行在网站子路径下时,路径处理比运行在根路径复杂得多。

外部可能看到:

/应用前缀/
/应用前缀/static/...
/应用前缀/api/...
/应用前缀/download/...

而 Flask 内部仍然可能使用:

/
/static/...
/api/...
/download/...

反向代理需要正确完成两件事:

  1. 将外部应用前缀映射到内部应用根路径;
  2. 告诉 Flask,用户访问时存在外部前缀。

否则容易出现:

  • 静态资源跳到网站根目录;
  • API 请求丢失前缀;
  • 下载链接错误;
  • 重定向跳出当前应用;
  • Cookie Path 不匹配;
  • url_for() 生成错误地址。

这种问题常常表现为“首页能打开,但按钮都不能用”。


八、代理头不能盲目信任

多层反向代理通常会传递:

  • 原始协议;
  • 原始主机名;
  • 客户端地址;
  • 外部路径前缀。

Flask 可以使用代理修正中间件处理这些头部,但必须明确知道有几层可信代理。

不能简单把所有代理层计数都设置为同一个数值,因为:

  • 某些头由边缘代理覆盖;
  • 某些头由内部代理追加;
  • 某些头只存在一层;
  • 客户端也可能自行伪造同名头部。

正确做法是检查真实请求链路,并且只信任已知代理注入的字段。

公开博客不应披露具体代理头计数、内部拓扑细节或可信网络范围。


九、Certbot 必须通过参数数组调用

后端调用 Certbot 时,必须使用参数数组:

command = [
    certbot_executable,
    "certonly",
    "--manual",
    "--preferred-challenges",
    "dns",
    "--manual-auth-hook",
    auth_hook_path,
    "--manual-cleanup-hook",
    cleanup_hook_path,
    "--non-interactive",
    "--agree-tos",
]

域名参数逐项追加:

for domain in domains:
    command.extend(["-d", domain])

然后使用:

subprocess.Popen(command, shell=False)

绝不能把用户输入拼接成 Shell 字符串。

需要禁止:

shell=True
os.system(...)
eval(...)
exec(...)

同时,用户不能自行提交额外 Certbot 参数、文件路径或命令选项。


十、严格校验域名输入

域名字段必须拒绝:

  • 带协议的 URL;
  • 带端口的地址;
  • IP 地址;
  • 本地主机名;
  • 内部域名后缀;
  • 文件路径;
  • 空白字符;
  • 控制字符;
  • 以参数前缀开头的字符串;
  • Shell 特殊字符;
  • 超出合理长度的输入。

子域名前缀也必须单独校验。

例如,页面允许用户输入:

主域名:example.tld
子域前缀:www
子域前缀:api
子域前缀:*

后端组合为:

example.tld
www.example.tld
api.example.tld
*.example.tld

组合完成后还应再次校验最终 FQDN。

若支持国际化域名,应先转换为 Punycode,再交给 Certbot。


十一、每个任务使用独立运行目录

不同任务不能共享 Certbot 的临时目录。

每个任务应拥有独立的:

任务目录/
├── Certbot 配置数据
├── Certbot 工作数据
├── Certbot 日志
├── challenge 状态
├── 用户批准状态
├── 待下载文件
└── 任务元数据

目录权限应严格限制,仅允许应用运行用户访问。

这样做有几个好处:

  • 任务之间不会互相污染;
  • 失败任务可以独立清理;
  • 调试时可以准确定位;
  • 不会把证书文件混入系统默认目录;
  • 可以避免不同申请共享状态。

公开文章不应暴露真实目录结构、绝对路径、任务目录命名方式或权限例外。


十二、Manual Auth Hook 是整个系统最难的部分

DNS-01 工具最容易误判的地方,是 Certbot manual auth hook 的工作方式。

表面上看,似乎可以:

一次展示所有 TXT
→ 用户全部添加
→ 一次性提交验证

但 Certbot 通常会逐条调用 challenge hook。

如果第一条 hook 一直等待,后续 challenge 可能根本不会生成。

因此,更稳妥的流程是:

Certbot 产生第一条 challenge
→ 页面显示 TXT
→ 用户添加记录
→ 系统查询 DNS
→ 用户确认继续
→ 当前 hook 返回
→ Certbot 产生下一条 challenge
→ 重复
→ 最后一条 challenge 通过
→ 用户确认签发
→ Certbot 提交验证

这不是程序卡死,而是 Certbot 的交互模型决定的。

页面必须清楚说明“当前正在处理第几条记录”,否则用户很容易以为系统只生成了一条 challenge。


十三、Hook 必须知道自己属于哪个任务

Certbot 会向 hook 提供当前域名、验证值和剩余 challenge 数量。

但这些信息不足以判断它属于哪个 Web 任务。

应用在启动 Certbot 时,还需要通过受控环境变量或安全的进程上下文传递:

  • 当前任务标识;
  • 任务运行环境;
  • 应用内部通信信息。

不能通过“寻找最新创建的任务目录”来猜测任务归属。

这种做法在并发、重试、异常残留或系统时间变化时都不可靠。

公开文章不应披露实际环境变量名称、内部通信文件名或任务定位规则。


十四、虚拟环境与 Hook 解释器

一个常见故障是:

  • 主应用运行在 Python 虚拟环境中;
  • hook 却由系统 Python 执行;
  • hook 导入依赖时失败。

因此,hook 必须:

  • 明确使用项目虚拟环境中的解释器;或
  • 只使用 Python 标准库;或
  • 通过本地受控接口与主应用通信。

不能假定 /usr/bin/env python3 一定会选中项目虚拟环境。

这个问题通常表现为:

主页面正常
Certbot 启动成功
challenge 始终不出现

真正原因可能只是 hook 在启动后立即因缺少依赖退出。


十五、TXT 记录名与多值记录

DNS challenge 记录名通常遵循:

根域名
→ _acme-challenge.根域名

普通子域名
→ _acme-challenge.子域名

通配符域名
→ _acme-challenge.根域名

根域名和通配符可能共用同一个 TXT 名称,但对应不同的 TXT 值。

因此,验证逻辑不能写成:

DNS 返回结果必须等于当前值

正确逻辑应是:

DNS 返回值集合中包含当前目标值

用户也必须看到明确提醒:

整个证书签发完成之前,不要删除前面已经添加的 TXT 记录。

否则第二条验证可能因为第一条 TXT 被过早删除而失败。


十六、权威 DNS 与公共 DNS

为了判断 TXT 是否已经传播,工具可以同时查询:

  • 权威 DNS;
  • 至少两个公共递归解析器。

页面应分别显示:

权威 DNS:已发现 / 未发现 / 查询失败
公共解析器 A:已发现 / 未发现 / 查询失败
公共解析器 B:已发现 / 未发现 / 查询失败

权威 DNS 查询时,需要先获取 NS 记录,再将 NS 主机名解析为 IP。

一些 DNS 库不接受 NS 主机名直接作为 nameserver 参数,只接受 IP 地址。若跳过这一步,可能出现类型错误。

DNS 查询还需要处理:

  • TXT 分片拼接;
  • NXDOMAIN;
  • SERVFAIL;
  • 查询超时;
  • 多个 TXT 值;
  • CNAME 或委派场景;
  • 不同解析器传播时间不一致。

错误不能直接冒泡成 HTTP 500,而应转成用户可理解的状态。


十七、任务状态必须持久化

任务状态不能只存放在:

  • Python 内存;
  • 浏览器变量;
  • 后台线程对象;
  • 单个进程的全局字典。

服务重启后,这些状态都会消失。

更可靠的方式是使用本地数据库持久化:

  • 任务标识;
  • 会话绑定摘要;
  • 域名列表;
  • 当前状态;
  • challenge 列表;
  • Certbot 进程状态;
  • 下载令牌摘要;
  • 创建和更新时间;
  • 错误摘要;
  • 过期时间。

数据库不应保存:

  • 私钥正文;
  • 明文下载令牌;
  • 会话 Secret;
  • 不必要的 ACME 私密数据。

页面刷新后,服务端根据匿名会话恢复当前任务。


十八、失败任务不能永远占用系统

全局只允许一个 Certbot 进程,是合理的资源保护措施。

但如果失败后没有释放锁,系统就会永久拒绝新任务。

所有异常路径都必须确保释放:

  • 进程锁;
  • 文件锁;
  • 全局活动标记;
  • 会话活动任务标记;
  • 数据库中的运行状态。

以下终止状态不能继续被视为活动:

failed
completed
downloaded
cancelled
expired
cleaned

失败任务应该继续显示错误摘要,并允许用户:

  • 关闭任务;
  • 重新开始。

如果状态接口只返回活动任务,那么任务一旦失败,页面就会突然显示“没有任务”,这会严重影响排错体验。


十九、一次性证书下载

签发成功后,下载包只应包含用户真正需要的文件:

证书链
私钥

不能包含:

  • Certbot 日志;
  • ACME 账户信息;
  • challenge 数据;
  • 任务元数据;
  • 内部目录结构;
  • 调试文件。

下载链接应使用高强度随机令牌。

服务端只保存令牌摘要,并验证:

  • 令牌是否有效;
  • 是否属于当前匿名会话;
  • 是否已经使用;
  • 是否过期;
  • 文件是否属于当前任务。

令牌首次成功使用后立即失效。

私钥不得进入普通静态资源目录,也不应通过可预测 URL 下载。

公开文章不应披露真实下载路径、令牌长度、存储字段名或清理时序细节。


二十、自动清理

任务需要根据状态进行清理:

  • 成功但未下载的任务,在短时间后清理;
  • 失败或中断任务,在较长时间后清理;
  • 正在运行的任务不得清理;
  • 下载完成后尽快清理;
  • 清理动作必须避免符号链接攻击和路径穿越。

不能直接把任务标识拼接进删除命令。

所有清理路径都必须经过:

  • 固定根目录约束;
  • 路径规范化;
  • 任务目录所有权验证;
  • 文件类型检查;
  • 符号链接检查。

具体超时时间和清理目录属于运维策略,不适合在公开文章中披露。


二十一、限流与资源保护

公开服务至少需要以下限制:

  • 单会话活动任务上限;
  • 单来源请求频率限制;
  • DNS 查询频率限制;
  • 全局 Certbot 并发限制;
  • 单张证书域名数量上限;
  • 请求体大小限制;
  • 输入长度限制;
  • 后台任务超时;
  • 不创建无限等待队列。

达到全局并发上限时,应返回明确提示,而不是让请求一直阻塞。

速率限制必须在服务端执行,不能只靠禁用按钮。


二十二、亮色界面与流程引导

证书申请工具不是日志终端。

最终界面采用亮色卡片式布局:

  • 浅色页面背景;
  • 白色内容卡片;
  • 深色正文;
  • 蓝色主要按钮;
  • 绿色成功提示;
  • 橙色等待提示;
  • 红色错误提示;
  • 清晰的焦点边框。

页面流程分为:

填写信息
→ 添加 TXT
→ 验证 DNS
→ 确认签发
→ 下载证书

未到达的步骤不提前展示大量空内容。

每条 challenge 独立显示:

  • 当前域名;
  • TXT 记录名;
  • TXT 值;
  • 复制按钮;
  • DNS 查询状态;
  • 下一步操作。

长 TXT 值必须自动换行,不能撑破移动端页面。

状态提示不能只依赖颜色,还要显示明确文字。


二十三、网站图标也是产品完整度的一部分

一个能用但没有 favicon 的页面,会显得像临时调试工具。

更合适的方式是复用网站现有的通用图标资源,而不是为单个工具重新生成一套。

模板可以引用站点现有的多尺寸 favicon:

<link rel="icon" href="/公共资源/favicon-large.png" sizes="512x512" type="image/png">
<link rel="icon" href="/公共资源/favicon-medium.png" sizes="32x32" type="image/png">
<link rel="icon" href="/公共资源/favicon-small.png" sizes="16x16" type="image/png">
<link rel="icon" href="/公共资源/favicon.ico" type="image/x-icon">

公开文章中应使用抽象路径,而不是公布真实资源目录。


二十四、自动化部署最容易犯的错误:操作错主机

多节点部署中,通常存在:

  • 自动化控制节点;
  • 应用服务器;
  • 反向代理服务器。

如果命令只写:

查看项目目录
检查服务状态
查询监听端口

自动化代理可能直接在当前控制节点执行。

然后得出:

项目不存在
服务不存在
端口为空

接着开始全盘搜索,甚至误判项目已丢失。

更安全的指令必须明确包含:

目标主机:应用节点
目标主机:代理节点

并在每次会话开始时执行主机身份确认:

hostname
hostname -I

只有主机名和地址都符合预期,才允许继续修改。

公开文章不应披露实际主机名、IP、SSH 端口或登录用户。


二十五、配置修改不能只追求“能运行”

集中式反向代理配置通常包含多个:

  • 域名;
  • 上游应用;
  • 主机分区;
  • 路径规则;
  • SSL 配置;
  • 通用代理规则。

新增应用路径时,不能简单追加到文件末尾。

必须确认:

  • 位于正确的 HTTPS 虚拟主机中;
  • 位于正确的应用分区中;
  • 位于通用路径规则之前;
  • 不与现有正则规则冲突;
  • 不改变无关配置;
  • 格式与周围代码一致。

语法检查通过只能证明配置可以加载,并不能证明它位于正确位置。

公开文章不应展示真实代理配置文件名、域名、上游地址、证书路径或 location 结构。


二十六、备份并不是越多越安全

修改系统级配置前保留备份是必要的。

但新项目如果每改一次源码都生成一份永久副本,很快就会出现大量:

源码.时间戳
源码.时间戳
源码.时间戳

这些文件可能:

  • 混淆当前版本;
  • 被 Web 服务器意外暴露;
  • 泄漏旧代码;
  • 保留已经修复的安全问题;
  • 增加恢复判断难度。

更合适的策略是:

  • 系统配置保留正式备份;
  • 应用源码使用版本控制;
  • 临时修改使用工作副本;
  • 测试成功后删除临时文件;
  • 不在 Web 根目录长期堆积源码备份。

尤其不能让历史源码副本通过静态服务器被下载。


二十七、公开工具还应隐藏什么

除了 IP、端口和路径,公开文章还应避免披露:

  • systemd 服务名称;
  • 实际运行用户;
  • 内部主机名;
  • 数据库文件位置;
  • 环境变量名;
  • Cookie 名称;
  • 下载接口结构;
  • 任务 UUID 格式;
  • 锁文件位置;
  • 清理线程实现;
  • 日志路径;
  • Certbot 账户目录;
  • 内部健康检查地址;
  • 防火墙规则;
  • 精确超时时间;
  • 真实依赖版本;
  • 真实错误日志;
  • 私钥权限例外;
  • 反向代理信任层数;
  • 任何能够帮助攻击者绘制内部拓扑的信息。

技术文章应该解释设计原则,而不是交付一份生产环境侦察报告。


二十八、最终流程

从用户视角看,最终工作流如下:

填写域名和邮箱
→ 创建匿名任务
→ 后台启动证书申请
→ 页面显示第一条 TXT
→ 用户修改 DNS
→ 系统查询多个 DNS 来源
→ 用户确认继续
→ 后续 challenge 逐条出现
→ 最后一条验证完成
→ 用户确认签发
→ 生成一次性下载
→ 下载后自动清理

整个过程中:

  • 页面不需要账户;
  • 不同用户任务彼此隔离;
  • Certbot 不接受任意参数;
  • 私钥不进入公开静态目录;
  • 页面刷新不会丢失状态;
  • 失败任务不会永久锁住系统;
  • 同名多值 TXT 得到正确处理;
  • 正式签发前存在明确确认;
  • 敏感部署细节不会暴露给浏览器。

结语

把 DNS-01 做成 Web 工具,真正困难的从来不是运行 Certbot。

难点在于:

  • 如何正确理解 hook 的逐条执行模型;
  • 如何处理异步人工操作;
  • 如何持久化状态;
  • 如何隔离匿名任务;
  • 如何保护私钥;
  • 如何限制资源滥用;
  • 如何恢复失败任务;
  • 如何隐藏内部部署细节;
  • 如何让用户始终知道下一步该做什么。

Certbot 只是证书执行器。

真正决定工具质量的,是状态机、安全边界、错误恢复、任务隔离和人机交互。

Leave a Reply

Your email address will not be published. Required fields are marked *