用 Go 自动同步腾讯云 SSL 证书到 Traefik:下载、校验与失败回滚

网站的 HTTPS 证书快到期时,通常要先取得新证书,再更新服务器上的 .crt 和 .key 文件,让入口服务加载它们。腾讯云控制台里已经出现新证书,服务器还要完成这一轮部署。漏掉后半段,访问网站时仍可能看到旧证书。

如果入口使用 Docker 里的 Traefik,还要确认文件放到了宿主机的正确目录,容器能看到更新后的内容,Traefik 也确实加载了新证书。单看下载成功或者重启命令执行成功,都还缺少一步验证。

这里用一个叫 ssl-sync 的 Go 工具,把这些步骤连起来。它查询腾讯云已签发的证书,下载并检查内容,更新目标文件,再通过一次实际的 TLS 连接确认结果。

当前代码只调用证书列表和下载接口。证书申请、域名验证、续期与重签发,需要由已有的云端流程完成。本文根据 2026 年 10 月 18 日读取的源码分析,文中域名、地址和路径均为示例,不代表生产服务器的实际配置。

先看证书从哪里来,最后由谁提供

HTTPS 连接里,服务器会向客户端出示证书。证书包含它覆盖的域名、公钥和有效期等信息;服务器还要持有相配的私钥,才能完成相应的身份认证过程。私钥应只保留在获准使用它的系统中。

证书文件可能包含多张证书。网站自身的那张叫叶证书,其他中间证书用于帮助客户端建立信任链。后面判断网站有没有换证时,工具比较的就是叶证书。

在本文的示例里,访问关系是:

1
浏览器 → Traefik 的 HTTPS 入口 → Gitea 等后端服务

Traefik 作为反向代理,接收访问请求,再转给后端。TLS 在 Traefik 这一层结束时,浏览器看到的是 Traefik 加载的证书。因此,更新文件和检查新证书是否生效,都要围绕这个入口进行。

Traefik 可以通过动态配置引用用户提供的证书:

1
2
3
4
tls:
certificates:
- certFile: /certs/gitea.crt
keyFile: /certs/gitea.key

这里的路径属于 Traefik 所在的运行环境。放在 Docker 容器里时,它们就是容器内路径。配置格式可对照 Traefik 的用户证书说明。

ssl-sync 负责把云端证书送到这些路径对应的宿主机文件,并让入口重新加载。一次执行的主要流程如下:

ssl-sync 从查询新证书到确认入口生效的流程

读源码时,可以按下面几个文件寻找对应步骤:

文件负责的事情
main.go组织同步流程,决定跳过、提交或回滚
tencent.go腾讯云 API 签名、证书查询与下载
certificate.go从 ZIP 选择证书和私钥,检查内容
deploy.go本机或 SSH 部署、备份与恢复
verify.go连接 TLS 入口并比较证书指纹
config.go读取配置、补默认值与校验参数

怎样从腾讯云选出要部署的证书

同一个域名可能有多张证书。续期或重签发之后,新旧证书也可能同时出现在列表里,因此不能只拿搜索结果中的第一条。

工具调用 DescribeCertificates,请求参数在 ListCertificates 中:

1
2
3
4
5
6
payload := map[string]any{
"Offset": 0,
"Limit": 1000,
"SearchKey": domain,
"CertificateType": "SVR",
}

SVR 表示服务器证书。SearchKey 是搜索关键词,会模糊匹配证书 ID、备注和域名,搜索命中还需要继续核对。参数定义见腾讯云证书列表接口。

取得列表后,代码保留 Status == 1 的候选,按 CertEndTime 降序排列;到期时间相同时,再比较 CertBeginTime。当前实现直接比较接口返回的时间字符串,依赖这些字段保持统一、可按字典序比较的格式。

这里的选择规则是优先尝试到期更晚的候选。随后逐张下载、解析,取第一张通过内容校验的证书。某个候选下载失败,或者证书不覆盖目标域名,程序会记录原因并尝试下一张。

所以,“选中的证书”还经过了下载包校验。列表里的名称和时间只负责排列尝试顺序。

配置也允许指定 certificate_id。填了以后会直接下载这张证书,跳过列表选择。它适合需要明确固定证书的场景;后续出现新 ID 时,这条配置仍指向原来的 ID,需要另行更新。

查询本身还有范围限制:当前只请求 Offset = 0 的一页,最多 1000 条,没有继续翻页。通配符或多域名证书能否进入候选,也取决于 SearchKey 的搜索结果。域名校验可以检查已下载的候选,无法补回根本没有搜到的证书。

API 签名怎样证明请求来自有权限的调用者

腾讯云 API 凭据和网站私钥分别用于两件事。前者授权程序查询、下载云端证书;后者用于网站的 TLS 连接。

ssl-sync 从环境变量读取 SecretId、SecretKey,使用临时凭据时还可以读取 Token。配置文件保存的是环境变量名称:

1
2
3
4
{
"secret_id_env": "TENCENTCLOUD_SECRET_ID",
"secret_key_env": "TENCENTCLOUD_SECRET_KEY"
}

这让普通配置与凭据值分开,降低把密钥随配置一起提交的概率。实际运行时,环境文件、进程权限和日志仍需要由部署环境管理。

代码使用 TC3-HMAC-SHA256 签名。它先把请求方法、路径、参与签名的请求头和请求体摘要拼成规范请求,再构造待签名字符串。接下来,用密钥按日期和服务名逐级派生签名密钥:

1
2
3
4
5
secretDate := hmacSHA256([]byte("TC3"+secretKey), []byte(date))
secretService := hmacSHA256(secretDate, []byte(tencentService))
secretSigning := hmacSHA256(secretService, []byte("tc3_request"))

signature := hex.EncodeToString(hmacSHA256(secretSigning, []byte(stringToSign)))

本工具的服务名是 ssl。请求头里的 Authorization 包含调用者 ID、凭证范围、签名头列表和计算出的签名,SecretKey 本身没有作为请求参数发送。腾讯云根据同样的规则验证签名,再检查调用权限。

签名中的日期按 UTC 计算,必须与请求时间戳对应。源码先调用 UTC(),再生成日期,避免本地日期与 UTC 日期跨日时不一致。这些规则可对照腾讯云签名方法 v3。

现有实现只用了 Go 标准库,没有引入腾讯云 SDK。这样可以直接看到签名过程;开发新的云 API 集成时,也可以采用官方 SDK,让 SDK 处理签名与请求细节。

ZIP 下载成功后,还要检查哪些内容

DownloadCertificate 返回的 Content 是 Base64 编码的 ZIP。程序先解码,再在内存里打开压缩包。下载接口文档说明了这一返回格式。

压缩包可能包含适配不同服务器的软件格式。工具会提高 Nginx 目录以及 _bundle.crt 等文件的候选优先级,但文件名只用于排序,实际内容还要通过检查。

extractCertificateBundle 主要检查:

检查要避免的问题
解析 PEM 和 X.509 证书文件存在,但内容无法作为证书读取
VerifyHostname(domain)备注或搜索命中正确,实际证书却不覆盖目标域名
检查 NotBefore新证书还没有到生效时间
检查 NotAfter证书已过期,或者剩余时间不够
tls.X509KeyPair证书和私钥没有配对

域名检查使用证书里的域名信息,具体匹配规则由 Go 的 VerifyHostname 实现。匹配证书与私钥则调用 X509KeyPair。

剩余有效期的判断在源码里是:

1
2
3
4
if !leaf.NotAfter.After(now.Add(minimumValidity)) {
reasons = append(reasons, certFile.name+": 剩余有效期不足 "+minimumValidity.String())
continue
}

例如,示例配置把 minimum_validity 设为 168h,要求证书剩余有效期大于 7 天。代码未配置时的默认值则是 24h。两者要分开看,不能把示例值当成程序默认值。

通过检查后,工具保留原来的证书 PEM 内容和匹配的私钥。它还限制 ZIP 内单文件及解压后总内容的大小,并且不按压缩包里的路径直接解压到目标目录。

这一阶段检查的是证书内容、域名、时间和密钥配对,没有单独调用 x509.Verify 构建完整信任链。部署后的 TLS 探测在默认配置下,还会经过客户端的常规证书验证。

为什么要同时比较证书和私钥的摘要

定时任务会反复执行。没有新内容时,重复写文件、重启 Traefik 都会造成额外操作。

工具为下载得到的证书文件和私钥分别计算 SHA-256,再读取目标位置的两个文件,比较它们的完整内容摘要:

1
2
3
4
5
if strings.EqualFold(current.Cert, bundle.CertHash) &&
strings.EqualFold(current.Key, bundle.KeyHash) {
log.Printf("[%s] 目标文件已是最新版本,无需重启", deployment.Name)
return nil
}

两个摘要都相同,这次同步就结束。这使得重复执行通常只发生查询、下载、校验和比较,不再触发部署。

完整文件摘要比较得很严格。即便叶证书相同,证书链内容或 PEM 文件的换行变化,也可能让摘要不同。私钥的编码形式改变同样可能触发更新。

这里还有一个运行边界:走到“内容相同”的分支后,代码直接返回,不再连接 HTTPS 入口。因此,这个定时工具不能代替持续的 HTTPS 监控。磁盘文件已经更新、服务却还在使用旧证书时,仅靠这条跳过分支发现不了问题。

新文件怎样替换旧文件

本机部署从 localDeployer.Begin 开始。它先为旧证书和私钥创建备份,再写入新内容,随后执行配置的重启命令。

备份存在的目的,是在新证书无法正常投入使用时恢复原来的文件。原来不存在的文件也会被记录下来,恢复时应把新建文件删除,而不是保留半套新配置。

写入时,程序在目标文件的同一目录创建临时文件。以私钥为例,主要顺序是:

1
2
3
4
5
6
在私钥目录创建临时文件
→ 设置权限为 0600
→ 写入完整的新私钥
→ Sync 并关闭临时文件
→ Rename 到目标文件名
→ Sync 所在目录

0600 表示只有文件所有者能读写。证书和私钥都写好后,工具才显式执行重启命令。

在面向 Linux 的这套路径里,同目录的重命名让单个目标文件从旧内容切换到完整新内容,减少其他进程读到半个文件的机会。Sync 则用于把文件内容和目录变化请求同步到存储层。相关接口见 Go 的 Rename 和 File.Sync。

这里需要保留两个区别。证书文件和私钥文件各自完成替换,两个文件没有在同一个文件系统事务里同时切换。如果另一个进程恰好在两次替换之间读取,仍可能短暂遇到新旧不配套的内容。

备份和恢复逻辑也依赖程序继续运行。它没有持久化事务日志,在进程被强制终止或机器断电以后,不会自动扫描上次的半成品并完成恢复。要进一步处理这类故障,需要另外设计版本目录、状态记录和启动恢复流程。

为什么重启成功以后,还要做 TLS 连接

文件更新成功、Docker 命令退出码为 0,分别证明了文件操作和命令执行的结果。网站实际提供哪张证书,还要从客户端连接时观察。

verify.go 会连接 verify.address,在 TLS 配置里设置 ServerName。它既用于域名验证,也让同一入口上的多个站点可以按 SNI 选择对应证书。配置还把最低 TLS 版本设为 1.2。

连接完成后,程序取得服务端给出的叶证书,再比较它与本次准备部署的叶证书:

1
2
3
4
5
6
7
8
servedHash := sha256.Sum256(certificates[0].Raw)
wantHash := sha256.Sum256(bundle.Leaf.Raw)
if servedHash != wantHash {
return fmt.Errorf("%s 仍在提供旧证书(当前 SHA256 %s,期望 %s)",
cfg.Address,
strings.ToUpper(hex.EncodeToString(servedHash[:8])),
strings.ToUpper(hex.EncodeToString(wantHash[:8])))
}

Raw 是证书的 DER 编码。程序比较的是完整 SHA-256 值,错误信息只展示摘要的前 8 个字节,方便识别当前和期望的证书。

前面用于决定是否部署的是两个文件的摘要;这里用于确认生效的是叶证书的指纹。它们比较的对象不同。

在默认配置下,Go TLS 客户端会先校验证书链和域名,然后工具再核对指纹。配置虽然允许设置 insecure_skip_verify,但开启后会跳过常规链和域名检查,剩下的指纹比较不能等同于正常的信任链验证。相关行为见 Go TLS 配置说明。

如果暂时没有观察到新证书,程序会等待 2 秒后再次尝试,默认验证窗口是 60s。这个窗口之外,单次连接本身还可能耗时,因此它不构成精确到秒的总耗时保证。

验证地址要指向真正要检查的 TLS 终止点。域名前面有 CDN 或其他负载均衡时,直接连接公网域名可能看到上游服务的证书。若目标是检查源站,可以把 address 配为明确的源站地址,同时保留目标域名作为 server_name。一次连接观察到的入口,也不能证明所有节点都已更新。

工具没有在这一步发送业务 HTTP 请求。TLS 指纹一致说明连接到的入口提供了预期证书,登录、页面内容和后端服务是否正常,还需要各自的检查。

部署怎样确认,失败怎样恢复

主流程把部署结果分成 Begin、Commit 和 Rollback:

阶段当前实现做什么
Begin保存旧文件,写入新文件,执行重启;备份继续保留
TLS 验证观察入口是否提供本次准备部署的叶证书
Commit接受本次更新,删除旧文件备份
Rollback恢复原文件,再次执行重启命令

本地文件写入或重启失败时,部署器内部会进入恢复逻辑。Begin 已成功、但后续 TLS 验证失败时,则由 main.go 调用 Rollback。

主流程给这次回滚创建了独立的 context:

1
2
3
rollbackCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
rollbackErr := transaction.Rollback(rollbackCtx)
cancel()

原来的执行可能已经因为 Ctrl+C 等原因取消。这里从 context.Background() 开始,给后续恢复单独留出执行窗口。进程如果已经被强制终止,这段代码仍然没有机会运行。

回滚本身也会失败。例如,旧文件恢复了,但再次重启仍然失败。源码会把两层错误一起返回,不能只看“已尝试回滚”就判定网站恢复正常。

还有一种状态是新证书已经通过验证,但清理备份失败。主流程会返回“证书已生效,但清理备份失败”。这和入口仍提供旧证书是两类问题,处理时应该保留这一区别。

放到 Docker 里,要分清两套路径

假设 Compose 把宿主机的证书目录映射进容器:

1
2
3
4
services:
traefik:
volumes:
- /opt/traefik/certs:/certs:ro

那么,两边使用的路径分别是:

使用位置证书路径示例
ssl-sync 在宿主机写入/opt/traefik/certs/gitea.crt
Traefik 在容器内读取/certs/gitea.crt

只读挂载 :ro 约束的是容器通过这个挂载进行写入,宿主机仍能更新对应目录。

这套实现采用临时文件加重命名,部署时应挂载整个证书目录。单独绑定挂载某个文件时,宿主机把文件重命名替换后,容器里的挂载可能继续关联旧文件,导致宿主机看到了新内容、容器仍读取旧内容。

Traefik 的 File provider 文档也说明了绑定挂载、文件重命名和变更通知之间的限制,并建议使用父目录挂载。该限制不能靠“重启命令成功”来排除,实际证书指纹检查仍有必要。

ssl-sync 示例选择在更新后重启 Traefik。这是当前工具采用的加载方式,不代表 Traefik 所有证书更新场景都必须重启。是否采用动态配置重载,需要结合 File provider 和实际挂载方式单独验证。

本机运行、SSH 和定时检查各负责什么

本机模式让程序直接运行在 Traefik 所在服务器,读取和替换本地文件。SSH 模式把校验后的证书和私钥打成 tar,通过 SSH 标准输入交给远端脚本。

两种部署器都实现相同的接口:

1
2
3
4
type Deployer interface {
CurrentHashes(context.Context, DeploymentConfig) (FileHashes, error)
Begin(context.Context, DeploymentConfig, *CertificateBundle) (Transaction, error)
}

主流程只负责比较内容和决定提交、回滚,具体文件操作交给部署器。SSH 的参数开启了 BatchMode=yes 和 StrictHostKeyChecking=yes,需要事先完成密钥登录和主机指纹登记。需要提权时使用 sudo -n,拿不到免密权限就失败,不等待输入密码。

远端使用临时文件加 mv 替换,并用 shell trap 处理部署脚本执行中的失败。它没有逐项实现本地部署器的文件和目录 Sync 调用,所以两种模式的落盘细节不能完全等同。

仓库还提供 systemd service 和 timer 模板。service 采用一次执行后退出的方式,timer 的主要设置是:

1
2
3
4
5
6
[Timer]
OnCalendar=*-*-* 03:17:00
OnCalendar=*-*-* 15:17:00
RandomizedDelaySec=30m
Persistent=true
Unit=ssl-sync.service

未另写时区时,这两个时间按服务器本地时区解释。随机延迟让实际执行时间向后分散;Persistent=true 则让定时器恢复激活时,有机会补做停机期间错过的日历任务,补做仍受随机延迟影响。systemd timer 手册说明了这些行为。

程序自身还用 flock 锁住配置的锁文件,避免使用同一把锁的进程同时执行。不同机器各自持有本机锁时,不能靠它协调对同一远端目标的并发更新。

多域名配置会依次执行。一项失败后,程序继续处理其他项目,最终用非零退出码表示本轮存在失败。每个项目各自提交或回滚,没有把所有域名打包成一个整体事务。

实际接入时,先核对这几个配置

下面是一份本机模式的示例。它要求程序运行位置能访问腾讯云 API,也能读写目标证书目录和执行重启命令:

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
{
"tencent_cloud": {
"minimum_validity": "168h"
},
"targets": {
"traefik-server": { "type": "local" }
},
"deployments": [
{
"name": "gitea",
"target": "traefik-server",
"domain": "git.example.com",
"cert_file": "/opt/traefik/certs/gitea.crt",
"key_file": "/opt/traefik/certs/gitea.key",
"restart": {
"working_directory": "/opt/traefik",
"command": ["docker", "compose", "restart", "traefik"]
},
"verify": {
"enabled": true,
"address": "git.example.com:443",
"server_name": "git.example.com",
"timeout": "60s"
}
}
]
}

domain 用于选择和校验证书;cert_file、key_file 指向宿主机实际文件;working_directory 对应 Compose 项目目录;verify 则决定从哪个入口观察哪一个域名的证书。把这些字段分别对上,比只确认 JSON 能解析更有用。

从源码开始,怎样实际运行

下面按 Linux 服务器、systemd 和 Docker Compose 的环境说明,把工具直接放在 Traefik 所在服务器运行。这样可以直接操作宿主机文件。新证书需要已经在腾讯云签发并允许下载,申请和续期仍由已有流程完成。

编译程序

如果源码已经在目标服务器上,而且已有 Go 1.21 或更新版本,在包含 go.mod、Makefile 的源码目录执行:

1
2
make test
make build

程序会生成在 bin/ssl-sync。这个项目没有第三方 Go 模块依赖。

如果在 Mac 上编译后上传 Linux 服务器,需要指定目标系统和 CPU 架构。在服务器运行 uname -m:x86_64 对应 Go 的 amd64,aarch64 对应 arm64。例如,为 x86_64 的 Linux 服务器编译:

1
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o bin/ssl-sync-linux-amd64 .

ARM64 服务器把 GOARCH 改成 arm64,输出文件名也相应改为 ssl-sync-linux-arm64。不能把默认编译出来的 macOS 程序直接放到 Linux 上运行。

在 Mac 编译时,将下面 4 个文件传到目标服务器的同一准备目录:

  • 对应架构的 Linux 程序。
  • config.example.json。
  • deploy/systemd/ssl-sync.service。
  • deploy/systemd/ssl-sync.timer。

后面的安装命令按在源码目录中执行书写。如果使用单独的上传目录,调整这些输入文件的路径即可。

使用安装器,保留已有配置和凭据

项目已增加 deploy/install.sh。它需要目标机已有 Python 3、Docker 和 systemd,使用预先编译的程序,不自动安装这些软件包。在源码目录中可以执行:

1
2
3
4
5
sudo ./deploy/install.sh --binary ./bin/ssl-sync \
--domain git.example.com \
--cert-file /opt/traefik/certs/gitea.crt \
--key-file /opt/traefik/certs/gitea.key \
--compose-dir /opt/traefik

也可以把完整配置作为 --config ./config.json 传入,适合多个域名。安装器保留已存在的 /etc/ssl-sync/config.json 和 /etc/ssl-sync/env;参数和示例只用于首次生成配置。升级时会备份被替换的程序、管理入口和 systemd 单元,文件写入失败时尝试恢复。

安装完成后,统一使用管理入口加载凭据和操作服务:

1
2
3
4
sudoedit /etc/ssl-sync/env
sudo /usr/local/sbin/ssl-sync-ctl check
sudo /usr/local/sbin/ssl-sync-ctl activate
sudo /usr/local/sbin/ssl-sync-ctl status

check 运行检查模式;activate 依次检查、正式同步一次、开启定时器。任一步失败,后面的步骤不再执行。默认安装只放置文件,不自动触发证书替换或重启;已有定时器在升级时会暂停,并恢复其原有运行状态。

API 凭据可以使用专门的 CAM 子用户。以管理员身份进入腾讯云的“访问管理 → 用户 → 用户列表”,选择“新建用户 → 自定义创建”,创建可访问资源的子用户。再通过自定义策略关联下面两个操作,完整策略示例已放入 deploy/tencent-policy.json:

1
2
3
4
5
6
7
8
9
10
{
"version": "2.0",
"statement": [
{
"effect": "allow",
"action": ["ssl:DescribeCertificates", "ssl:DownloadCertificate"],
"resource": ["*"]
}
]
}

这份策略的资源范围是账号证书。给子用户关联策略后,在其详情页创建 API 密钥,将生成的 SecretId 和 SecretKey 填入服务器的凭据文件。SecretKey 需要在创建时保存。具体入口见创建子用户、创建自定义策略和子账号 API 密钥管理。

需要逐步核对时,手动安装程序和配置

在服务器上执行:

1
2
3
4
5
6
sudo install -m 0755 bin/ssl-sync /usr/local/bin/ssl-sync
sudo install -d -m 0700 /etc/ssl-sync
sudo install -m 0600 config.example.json /etc/ssl-sync/config.json
sudo touch /etc/ssl-sync/env
sudo chmod 0600 /etc/ssl-sync/env
sudoedit /etc/ssl-sync/env /etc/ssl-sync/config.json

如果上传的是 ssl-sync-linux-amd64,第一条命令的输入文件替换为它,安装后的名字仍是 /usr/local/bin/ssl-sync。这些命令面向首次安装;已经有配置时,保留原配置并核对字段,避免用示例覆盖现有内容。

env 文件填入专门用于这项任务的 API 凭据,下面的中文内容需要替换为真实值:

1
2
TENCENTCLOUD_SECRET_ID='填入专用子用户的SecretId'
TENCENTCLOUD_SECRET_KEY='填入专用子用户的SecretKey'

保持每行 名称=值 的写法,不在行首添加 export。这里用简单的单引号赋值,兼容下文的 shell 加载和 service 的 EnvironmentFile。临时凭据还要增加 TENCENTCLOUD_SESSION_TOKEN,并由调用方负责更新过期凭据。

该身份需要允许 ssl:DescribeCertificates 和 ssl:DownloadCertificate。列表与下载接口的资源授权粒度不同,可以按腾讯云 SSL 的 CAM 接口授权表配置访问范围。凭据只在服务器的受限文件里填写,示例和文章中不放真实值。

程序当前通过环境变量接收凭据。文件权限能限制直接读取,不能替代完整的密钥管理;systemd 对环境变量和 EnvironmentFile 的行为可查执行环境文档。

接着在 config.json 改好前一节列出的域名、宿主机证书路径、Compose 目录、服务名和验证入口。restart.command 中的 traefik 必须与实际 Compose 服务名一致。通常保留 certificate_id 为空,让程序每次重新选择候选。

先执行一次检查模式

直接运行程序不会自动加载 /etc/ssl-sync/env。下面用 root shell 显式读取刚才创建的赋值文件,再将变量传给程序:

1
2
3
4
5
6
7
sudo sh -c '
set -eu
set -a
. /etc/ssl-sync/env
set +a
exec /usr/local/bin/ssl-sync -config /etc/ssl-sync/config.json -dry-run
'

这次会读取凭据、查询并下载证书、校验内容和读取目标文件摘要,也会使用进程锁;发现变化后停在部署之前,不覆盖目标证书或执行重启。SSH 模式也需要能连接目标机器读取摘要。检查模式通过后,写入权限、重启和入口验证仍需要在实际执行时确认。

如果提示缺少凭据,先核对文件中的变量名称和加载方式;提示没有可用证书时,再看查询结果、域名覆盖、下载权限和剩余有效期。示例的 168h 会拒绝剩余不足 7 天的候选。

安装 service,再手动正式运行一次

确认检查模式的证书与目标路径符合预期后,安装 systemd 模板,再执行正式部署:

1
2
3
4
5
sudo install -m 0644 deploy/systemd/ssl-sync.service /etc/systemd/system/ssl-sync.service
sudo install -m 0644 deploy/systemd/ssl-sync.timer /etc/systemd/system/ssl-sync.timer
sudo systemctl daemon-reload
sudo systemctl start ssl-sync.service
sudo journalctl -u ssl-sync.service -n 100 --no-pager

service 会通过 EnvironmentFile=/etc/ssl-sync/env 加载凭据,无需在终端再次导出变量。有证书变化时,这一步会覆盖目标文件并执行配置的重启命令,入口服务可能短暂中断。重启与验证的结果以这次实际日志为准。

这是一次执行后退出的 service,无需保持常驻。可以查看 systemd 记录的结果:

1
sudo systemctl show ssl-sync.service -p Result -p ExecMainStatus

没有证书变化时,日志会记录无需重启;执行了更新并通过验证时,才会记录同步成功。前文提到的跳过分支仍然存在,任务成功退出不能替代持续的 HTTPS 监控。

确认单次运行后,再开启定时器

1
2
sudo systemctl enable --now ssl-sync.timer
sudo systemctl list-timers --all ssl-sync.timer

模板按服务器本地时间每天检查两次,并加入随机延迟。停止后续定时检查可以执行:

1
sudo systemctl disable --now ssl-sync.timer

停止 timer 不会撤销已经完成的证书更新,也不会终止一项已经在运行的 service。后续查看执行结果,仍使用前面的 journalctl 命令。

如果改从 Mac 经 SSH 操作服务器,使用本机编译的 macOS 程序和 config.ssh.example.json,填好目标主机、登录用户、密钥与 known_hosts 路径;其中证书路径和 Compose 目录都属于远端服务器。SSH 方式仍需要当前运行机器能读取 API 凭据并保持运行,远端权限和主机指纹也要事先配置好。

本文核对了现有实现,并运行其已有的 12 个 Go 单元测试和 go vet,结果通过。测试覆盖配置、签名固定样例、证书筛选、本地文件提交与恢复、部分 SSH 脚本恢复路径。安装器另有 10 项测试通过,覆盖配置与凭据保留、重复安装、升级恢复,以及检查或正式运行失败时不启用定时器。SSH 单元测试用本地假命令执行脚本,没有连接真实服务器;签名测试也没有调用腾讯云 API。

上述测试结果没有包含真实腾讯云账号联调、生产 Docker 重启、线上 TLS 换证验证或进程崩溃后的恢复验证。正式接入时,可以按配置检查、检查模式、一次实际部署和入口证书确认的顺序逐项记录结果。