修复外部证书签发与续期文件命名不一致
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-10-02 15:15:01 +08:00
parent 73753ac4e1
commit 9465b4fee9
5 changed files with 21 additions and 16 deletions

View File

@ -26,7 +26,7 @@ func run(args []string) error {
historySource := flags.String("deployment-history", "", "停服复制完成后,从原部署导入配置历史并转换证书引用")
exportTLS := flags.String("export-project-tls", "", "将项目托管证书导出为本机 TLS 入口使用的合并 PEM 文件")
publishTLS := flags.String("publish-external-tls", "", "将外部续期证书校验并原子发布为 Nginx 使用的合并 PEM 文件")
externalTLSDirectory := flags.String("external-tls-dir", "", "外部续期目录,包含权限受保护的 smail.crt 和 smail.key")
externalTLSDirectory := flags.String("external-tls-dir", "", "外部续期目录,包含以邮件主域名命名且权限受保护的证书与私钥")
if err := flags.Parse(args); err != nil {
return err
}

View File

@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"git.pcux.cn/xuans/Smail/utils/atomicfile"
)
@ -38,7 +39,7 @@ func ExportProjectTLS(root, destination string) error {
}
// PublishExternalTLS 仅由部署管理员显式提供外部续期目录,不从网页配置读取高权限源路径。
// 外部工具统一使用 smail 文件名;校验失败时不替换入口的有效证书。
// 外部工具使用邮件主域名作为文件名,与续期读取规则一致;校验失败时保留入口证书。
func PublishExternalTLS(root, sourceDirectory, destination string) error {
if runtime.GOOS != "linux" {
return errors.New("本机 Nginx 证书同步仅支持 Linux")
@ -57,7 +58,11 @@ func PublishExternalTLS(root, sourceDirectory, destination string) error {
if filepath.Dir(destination) == sourceDirectory {
return ErrUnsafeSSLFile
}
return exportTLSBundle(sourceDirectory, "smail.crt", "smail.key", destination, validationCertificateDomains(&runtime))
domain := runtime.Root.Domains.Primary
if domain == "" || domain == "." || domain == ".." || strings.ContainsAny(domain, `/\:`) {
return ErrUnsafeSSLFile
}
return exportTLSBundle(sourceDirectory, domain+".crt", domain+".key", destination, validationCertificateDomains(&runtime))
}
func exportTLSBundle(sourceDirectory, certificateName, privateKeyName, destination string, domains []string) error {

View File

@ -35,8 +35,8 @@ func TestPublishExternalTLSValidatesBeforeReplacingBundle(t *testing.T) {
t.Fatal(err)
}
}
write("smail.crt", certificate)
write("smail.key", key)
write("example.com.crt", certificate)
write("example.com.key", key)
destination := filepath.Join(output, "tls.pem")
if err := PublishExternalTLS(root, source, destination); err != nil {
t.Fatal(err)
@ -69,22 +69,22 @@ func TestPublishExternalTLSValidatesBeforeReplacingBundle(t *testing.T) {
t.Fatal("failed publication replaced the previous bundle")
}
}
write("smail.key", []byte("invalid"))
write("example.com.key", []byte("invalid"))
assertRejected()
wrongKey, wrongCertificate := testManagedCertificate(t, "other.example", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048)
write("smail.key", wrongKey)
write("smail.crt", wrongCertificate)
write("example.com.key", wrongKey)
write("example.com.crt", wrongCertificate)
assertRejected()
write("smail.key", key)
write("smail.crt", certificate)
if err := os.Chmod(filepath.Join(source, "smail.key"), 0644); err != nil {
write("example.com.key", key)
write("example.com.crt", certificate)
if err := os.Chmod(filepath.Join(source, "example.com.key"), 0644); err != nil {
t.Fatal(err)
}
assertRejected()
if err := os.Chmod(filepath.Join(source, "smail.key"), 0600); err != nil {
if err := os.Chmod(filepath.Join(source, "example.com.key"), 0600); err != nil {
t.Fatal(err)
}
if err := PublishExternalTLS(root, source, filepath.Join(source, "smail.crt")); err == nil {
if err := PublishExternalTLS(root, source, filepath.Join(source, "example.com.crt")); err == nil {
t.Fatal("source overwrite allowed")
}
candidate.Root.HTTP.TLSMode = HTTPSTLSModeDirect

View File

@ -8,7 +8,7 @@ Type=oneshot
EnvironmentFile=/etc/smail/acme-dns.env
EnvironmentFile=/etc/smail/acme-renew.env
ExecStartPre=/usr/sbin/nginx -t
ExecStart=/usr/local/libexec/smail-acme/lego --path /var/lib/smail-acme-tls --email ${SMAIL_ACME_EMAIL} --accept-tos --key-type ec256 --dns ${SMAIL_ACME_DNS_PROVIDER} --domains ${SMAIL_MAIL_DOMAIN} --domains *.${SMAIL_MAIL_DOMAIN} --filename smail --http-timeout 20 --cert.timeout 120 renew --days 30 --reuse-key --no-random-sleep
ExecStart=/usr/local/libexec/smail-acme/lego --path /var/lib/smail-acme-tls --email ${SMAIL_ACME_EMAIL} --accept-tos --key-type ec256 --dns ${SMAIL_ACME_DNS_PROVIDER} --domains ${SMAIL_MAIL_DOMAIN} --domains *.${SMAIL_MAIL_DOMAIN} --http-timeout 20 --cert.timeout 120 renew --days 30 --reuse-key --no-random-sleep
ExecStartPost=/opt/smail/smail-config --root /opt/smail --external-tls-dir /var/lib/smail-acme-tls/certificates --publish-external-tls /etc/nginx/smail/tls.pem
ExecStartPost=/usr/sbin/nginx -t
ExecStartPost=/usr/bin/systemctl reload nginx.service

View File

@ -57,7 +57,7 @@
1. 安装与项目依赖版本一致的 lego 到 `/usr/local/libexec/smail-acme/lego`,安装最新版 `smail-config` 到 `/opt/smail/smail-config`。
2. 创建权限为 `0700` 的 `/var/lib/smail-acme-tls`,以及权限为 `0600`、root 所有的 `/etc/smail/acme-dns.env` 与 `/etc/smail/acme-renew.env`。按对应示例填写,禁止输出凭据或加入仓库。
3. 首次使用相同参数执行 lego 的 `run` 完成签发,不能直接用缺少账户和证书状态的 `renew` 初始化。固定使用 `--filename smail`;申请邮件根域及其泛域名,验证方式为 DNS。本模板要求后台域名被该证书覆盖;不要重复加入已被泛域名覆盖的后台子域,否则签发机构可能拒绝请求。独立的后台域名需要管理员额外加入明确的申请参数。
3. 首次使用相同参数执行 lego 的 `run` 完成签发,不能直接用缺少账户和证书状态的 `renew` 初始化。以邮件主域名作为第一个域名参数,使用工具默认的域名文件名,不设置已弃用的 `--filename`,否则签发保存和续期读取的文件名可能不一致。申请邮件根域及其泛域名,验证方式为 DNS。本模板要求后台域名被该证书覆盖;不要重复加入已被泛域名覆盖的后台子域,否则签发机构可能拒绝请求。独立的后台域名需要管理员额外加入明确的申请参数。
4. 执行下列发布命令。该命令校验证书有效期、域名、密钥匹配及文件权限,拒绝符号链接、不安全路径和源目录内覆盖;失败时保留原有 Nginx 合并证书。
```sh
@ -66,7 +66,7 @@
--publish-external-tls /etc/nginx/smail/tls.pem
```
5. Nginx 的 `ssl_certificate` 与 `ssl_certificate_key` 都引用 `/etc/nginx/smail/tls.pem`。通过 Smail 页面将外部证书路径设置为 `/var/lib/smail-acme-tls/certificates/smail.crt`、私钥路径设置为同目录的 `smail.key`;运行账号必须具备读取权限,不能通过开放私钥权限解决访问问题。
5. Nginx 的 `ssl_certificate` 与 `ssl_certificate_key` 都引用 `/etc/nginx/smail/tls.pem`。通过 Smail 页面将外部证书路径设置为 `/var/lib/smail-acme-tls/certificates/<邮件主域名>.crt`、私钥路径设置为同目录的 `<邮件主域名>.key`,与本次初始化确认的邮件主域名一致;运行账号必须具备读取权限,不能通过开放私钥权限解决访问问题。
6. 检查 Nginx 配置、平滑加载,并分别核验网页及邮件协议的公网证书。确认首次切换成功后,再安装并启用专用续期 service、timer;不要启用同机其他项目的全局续期任务,也不要同时保留旧的项目证书同步监听器。
专用计时器每天检查一次,并随机延迟;到期阈值为三十天,续期复用当前证书私钥。续期成功后重新校验并原子发布合并证书,再检查和加载 Nginx。Smail 从外部证书文件热加载,保持其无效新证书回退行为。不要在该任务运行期间另外手工调用同一状态目录的 lego。