diff --git a/backend/cmd/smail-config/main.go b/backend/cmd/smail-config/main.go index eaf0acc..3806219 100644 --- a/backend/cmd/smail-config/main.go +++ b/backend/cmd/smail-config/main.go @@ -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 } diff --git a/backend/config/ssl_export.go b/backend/config/ssl_export.go index 0a49bc7..c37fbce 100644 --- a/backend/config/ssl_export.go +++ b/backend/config/ssl_export.go @@ -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 { diff --git a/backend/config/ssl_export_test.go b/backend/config/ssl_export_test.go index 5e3dee1..f9cddc0 100644 --- a/backend/config/ssl_export_test.go +++ b/backend/config/ssl_export_test.go @@ -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 diff --git a/deploy/nginx/smail-certificate-renew.service b/deploy/nginx/smail-certificate-renew.service index 9bc89dc..15a5f92 100644 --- a/deploy/nginx/smail-certificate-renew.service +++ b/deploy/nginx/smail-certificate-renew.service @@ -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 diff --git a/docs/HTTPS反向代理部署说明.md b/docs/HTTPS反向代理部署说明.md index 5d869b1..dec6f43 100644 --- a/docs/HTTPS反向代理部署说明.md +++ b/docs/HTTPS反向代理部署说明.md @@ -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。