在将托管于腾讯云(DNSPod)的域名跨账号迁移,并相应更新了 Caddyfile 中的 API 密钥(SecretId 和 SecretKey)后,Caddy 突然无法通过 dns-01 挑战自动续期 SSL 证书。本文记录了该故障的排查过程、底层技术原理以及最终的解决方案。
一、 问题现象
在证书即将到期时,Caddy 的系统日志中持续出现以下报错:
1
2
3
4
5
6
7
8
{
"level" : "error" ,
"logger" : "tls.renew" ,
"msg" : "could not get certificate from issuer" ,
"identifier" : "nas.suikaxhq.top" ,
"issuer" : "acme-v02.api.letsencrypt.org-directory" ,
"error" : "[nas.suikaxhq.top] solving challenges: presenting for challenge: adding temporary record for zone \"suikaxhq.top.\": returned value is not valid"
}
报错信息非常晦涩:returned value is not valid (返回值非法)。
二、 底层原理分析
为什么 Caddy 会抛出如此模糊的错误信息?这需要从 Caddy 的腾讯云 DNS 插件(底层依赖 libdns/tencentcloud 库)的源码说起。
在 libdns/tencentcloud/client.go 中,向腾讯云 API 提交创建 DNS 解析记录请求的逻辑如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
resp, err := p.sendRequest (ctx, CreateRecord, string (payload))
if err != nil {
return err
}
var response Response
if err = json.Unmarshal (resp, & response); err != nil {
return err
}
if response.Response.RecordId == 0 {
return ErrNotValid
}
而在 types.go 中,错误被静态定义为:
1
var ErrNotValid = errors.New ("returned value is not valid" )
逻辑漏洞 :
当腾讯云 API 返回错误(如签名失败、域名不存在、权限不足等)时,其返回的响应中自然不会包含有效的 RecordId(反序列化后为默认值 0)。该库并未解析腾讯云具体的 Response.Error 结构,而是直接判断 RecordId == 0 并将其粗暴地统一归类为 returned value is not valid 抛出。
这导致所有来自腾讯云 API 端具体的业务错误均被掩盖 ,加大了排障难度。
三、 排障过程与三大“深坑”
为了找出被遮蔽的真实 API 错误,排障过程分为以下几个阶段:
1. 编写独立诊断脚本突破“错误掩盖”
为获取腾讯云 API 的真实报错信息,使用 Python 标准库(不依赖第三方 SDK 或 requests)实现了一个腾讯云 API 3.0(TC3-HMAC-SHA256 签名算法)的独立测试脚本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import sys
import hashlib
import hmac
import json
import time
import urllib.request
import urllib.error
def sign (key, msg):
return hmac. new(key, msg. encode('utf-8' ), hashlib. sha256). digest()
def test_tencent_dns (secret_id, secret_key, domain):
service = "dnspod"
host = "dnspod.tencentcloudapi.com"
endpoint = f "https:// { host} "
action = "CreateRecord"
version = "2021-03-23"
timestamp = int (time. time())
date = time. strftime('%Y-%m- %d ' , time. gmtime(timestamp))
params = {
"Domain" : domain,
"SubDomain" : "_acme-challenge.nas" ,
"RecordType" : "TXT" ,
"RecordLine" : "默认" ,
"Value" : "test-token-from-diagnostic-script" ,
"TTL" : 600
}
payload = json. dumps(params)
hashed_payload = hashlib. sha256(payload. encode('utf-8' )). hexdigest()
canonical_headers = f "content-type:application/json; charset=utf-8 \n host: { host} \n "
signed_headers = "content-type;host"
canonical_request = f "POST \n / \n\n { canonical_headers} \n { signed_headers} \n { hashed_payload} "
credential_scope = f " { date} / { service} /tc3_request"
hashed_canonical_request = hashlib. sha256(canonical_request. encode('utf-8' )). hexdigest()
string_to_sign = f "TC3-HMAC-SHA256 \n { timestamp} \n { credential_scope} \n { hashed_canonical_request} "
secret_date = sign(("TC3" + secret_key). encode('utf-8' ), date)
secret_service = sign(secret_date, service)
secret_signing = sign(secret_service, "tc3_request" )
signature = hmac. new(secret_signing, string_to_sign. encode('utf-8' ), hashlib. sha256). hexdigest()
authorization = f "TC3-HMAC-SHA256 Credential= { secret_id} / { credential_scope} , SignedHeaders= { signed_headers} , Signature= { signature} "
headers = {
"Authorization" : authorization,
"Content-Type" : "application/json; charset=utf-8" ,
"Host" : host,
"X-TC-Action" : action,
"X-TC-Version" : version,
"X-TC-Timestamp" : str (timestamp)
}
req = urllib. request. Request(endpoint, data= payload. encode('utf-8' ), headers= headers, method= 'POST' )
try :
with urllib. request. urlopen(req) as response:
resp_body = response. read(). decode('utf-8' )
parsed_resp = json. loads(resp_body)
print (json. dumps(parsed_resp, indent= 2 , ensure_ascii= False ))
except Exception as e:
print (f "异常: { e} " )
测试结果 :将 Caddyfile 中配置的密钥输入测试脚本,API 调用竟然返回 200 OK 且成功创建了 TXT 记录。
这表明:新密钥物理上完全正确,且新账号的权限无误 。问题必定出在 Caddy 这一端。
2. 第一坑:配置文件路径与多备份冲突
在检查系统服务实际加载的配置时,发现当前运行的系统服务指向的是 /etc/caddy/Caddyfile,而该文件中的密钥依然是旧账号的旧密钥(AKIDZ0E...) 。
原来,在进行密钥更换时,修改的仅仅是工作空间或备份路径下的临时 Caddyfile,导致 Caddy 系统服务在重启后依然读取旧的配置文件。
3. 第二坑:僵死进程导致端口占用与配置未刷新
在修改了真实的 /etc/caddy/Caddyfile 并重启服务时,遇到了以下报错:
1
listen tcp 127.0.0.1:2019: bind: address already in use
这说明系统里已经有一个脱离 systemd 托管的旧 Caddy 进程正常驻后台运行,并占用了 Caddy 的管理控制端口。
后果 :该僵死进程一直霸占着流量处理权,且由于不读取新的配置文件,导致新密钥始终没有真正应用到运行中的进程里。
解决 :使用 lsof -i :2019 或 netstat -tunlp 找到占用端口的旧进程 PID,通过 kill -9 强行终止,随后顺利启动了 systemd 中的新服务。
4. 第三坑:Caddy 证书管理器(Certmagic)的历史缓存机制
在正确应用了新 Caddyfile 配置文件并重启服务后,我们发现 Caddy 日志中居然仍在不断尝试为已经废弃的旧域名(nas.suikaxhq.top、komga.suikaxhq.top,当前 Caddyfile 已经废弃了这两个三级域名,改为了四级子域名)申请证书并抛出错误。
机制解析 :Caddy 的自动证书管理核心(Certmagic)会将未完成的验证订单以及先前配置的证书申请路径,持久化存储在数据目录(默认在 /var/lib/caddy/.local/share/caddy)中。
后果 :在服务重启时,Certmagic 会读取本地的历史缓存,继续尝试完成未完结的历史域名证书续期订单,并继续使用缓存中当时关联的旧 Issuer 凭证,从而继续产生报错。
解决 :彻底停止 Caddy 服务后,运行 rm -rf /var/lib/caddy/.local/share/caddy/acme/ 清理掉历史 ACME 订单缓存与相关域名的本地证书缓存。重新启动服务后,Caddy 会根据全新 Caddyfile 中的定义与正确的新密钥重新发起验证。
四、 总结与避坑指南
小心 Caddy 插件的粗暴错误映射 :当使用第三方的 Caddy DNS 挑战插件时,若遇到诸如 returned value is not valid 这类语义不明的错误,应优先考虑通过独立 API 调试脚本(或腾讯云 API Explorer)直接进行请求抓取。
避免 systemd 环境变量盲区 :直接在 Linux 系统上以 systemctl 服务运行的 Caddy 无法继承当前 Shell 会话的环境变量。若在 Caddyfile 中引用环境变量({$VAR}),须通过 systemctl edit caddy 在 [Service] 中显式声明 Environment=。
注意清理 Certmagic 数据缓存 :Caddyfile 中的修改(如删除域名、更改 Issuer 凭证等)在某些情况下可能被数据目录中的旧持久化缓存(ACME Order 状态)劫持。当配置修改后产生顽固的历史域名报错时,应及时清理 /var/lib/caddy/.local/share/caddy/acme/ 目录。