为域名申请 HTTPS 证书时,HTTP-01 往往是最省事的方式。但在一些环境中,DNS-01 更合适:
- 需要申请通配符证书;
- 域名尚未指向业务服务器;
- Web 服务不方便临时修改;
- 证书申请节点与业务节点彼此分离;
- 网络入口不允许开放额外验证端口。
DNS-01 的基本原理很简单:证书机构要求申请者在指定域名下添加一条 TXT 记录,查询到正确值后,即可确认申请者拥有该域名的 DNS 控制权。
Certbot 已经能够在命令行中完成这一流程,但命令行操作并不适合所有用户。尤其是在申请根域名、多个子域名和通配符证书时,TXT 记录的管理、验证顺序、失败恢复和证书下载都会迅速变得复杂。
因此,一个围绕 DNS-01 构建的 Web 工具,价值并不在于“把命令搬到网页上”,而在于把整个异步验证过程变成一个安全、清晰、可恢复的状态机。
本文已对域名、主机、目录、端口、服务名、接口路径、文件名和部署拓扑进行抽象处理,不包含实际生产环境中的可定位信息。
一、工具的目标是什么
这个工具面向无法或不愿直接操作 Certbot 命令行的用户。
用户只需要完成几件事:
- 输入主域名和联系邮箱;
- 添加需要申请的子域名或通配符;
- 根据页面提示添加 TXT 记录;
- 等待 DNS 查询通过;
- 确认证书申请;
- 下载证书和私钥。
系统负责:
- 生成证书申请任务;
- 调用 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/...
反向代理需要正确完成两件事:
- 将外部应用前缀映射到内部应用根路径;
- 告诉 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 只是证书执行器。
真正决定工具质量的,是状态机、安全边界、错误恢复、任务隔离和人机交互。