From bc50eab30e6d083a2f48a18e5b236403c46218dc Mon Sep 17 00:00:00 2001 From: smail Date: Fri, 2 Oct 2026 14:45:21 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=8F=8D=E4=BB=A3=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E5=A4=96=E9=83=A8=E8=AF=81=E4=B9=A6=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E4=B8=8EGo=E7=BB=AD=E6=9C=9F=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/smail-config/main.go | 14 +++- backend/config/ssl_export.go | 33 +++++++- backend/config/ssl_export_test.go | 82 ++++++++++++++++++++ deploy/nginx/acme-renew.env.example | 8 +- deploy/nginx/smail-certificate-renew.service | 16 ++-- docs/HTTPS反向代理部署说明.md | 24 ++++++ 6 files changed, 164 insertions(+), 13 deletions(-) diff --git a/backend/cmd/smail-config/main.go b/backend/cmd/smail-config/main.go index 0cf1e2a..eaf0acc 100644 --- a/backend/cmd/smail-config/main.go +++ b/backend/cmd/smail-config/main.go @@ -25,6 +25,8 @@ func run(args []string) error { runtimeData := flags.Bool("runtime-data", false, "将运行数据迁到 data,保留当前五个配置文件") 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") if err := flags.Parse(args); err != nil { return err } @@ -36,7 +38,7 @@ func run(args []string) error { return fmt.Errorf("服务根目录无效") } if *exportTLS != "" { - if *apply || *runtimeData || *historySource != "" || !filepath.IsAbs(*exportTLS) { + if *apply || *runtimeData || *historySource != "" || *publishTLS != "" || *externalTLSDirectory != "" || !filepath.IsAbs(*exportTLS) { return fmt.Errorf("证书导出必须单独执行,目标必须是绝对路径") } if err := config.ExportProjectTLS(absolute, filepath.Clean(*exportTLS)); err != nil { @@ -45,6 +47,16 @@ func run(args []string) error { fmt.Println("项目证书同步检查完成。") return nil } + if *publishTLS != "" || *externalTLSDirectory != "" { + if *apply || *runtimeData || *historySource != "" || !filepath.IsAbs(*publishTLS) || !filepath.IsAbs(*externalTLSDirectory) { + return fmt.Errorf("外部证书发布必须单独执行,源目录和目标文件必须同时指定绝对路径") + } + if err := config.PublishExternalTLS(absolute, *externalTLSDirectory, *publishTLS); err != nil { + return fmt.Errorf("外部证书发布失败,原有入口证书保留:%w", err) + } + fmt.Println("外部证书校验及发布完成。") + return nil + } options := config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true} if *historySource != "" { if !*apply || *runtimeData { diff --git a/backend/config/ssl_export.go b/backend/config/ssl_export.go index 385c16a..0a49bc7 100644 --- a/backend/config/ssl_export.go +++ b/backend/config/ssl_export.go @@ -34,6 +34,33 @@ func ExportProjectTLS(root, destination string) error { return ErrInvalidSSLReference } sourceDirectory := filepath.Join(root, "ssl") + return exportTLSBundle(sourceDirectory, filepath.Base(publicPath), filepath.Base(privatePath), destination, validationCertificateDomains(&runtime)) +} + +// PublishExternalTLS 仅由部署管理员显式提供外部续期目录,不从网页配置读取高权限源路径。 +// 外部工具统一使用 smail 文件名;校验失败时不替换入口的有效证书。 +func PublishExternalTLS(root, sourceDirectory, destination string) error { + if runtime.GOOS != "linux" { + return errors.New("本机 Nginx 证书同步仅支持 Linux") + } + if !filepath.IsAbs(root) || !filepath.IsAbs(sourceDirectory) || !filepath.IsAbs(destination) { + return ErrUnsafeSSLFile + } + root, sourceDirectory, destination = filepath.Clean(root), filepath.Clean(sourceDirectory), filepath.Clean(destination) + runtime, err := NewConfigLoader(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}).Load() + if err != nil { + return ErrInvalidSSL + } + if runtime.Root.HTTP.TLSMode != HTTPSTLSModeProxy || EffectiveSSLManagement(runtime.SSL.TLS) == SSLManagementProject { + return ErrInvalidSSL + } + if filepath.Dir(destination) == sourceDirectory { + return ErrUnsafeSSLFile + } + return exportTLSBundle(sourceDirectory, "smail.crt", "smail.key", destination, validationCertificateDomains(&runtime)) +} + +func exportTLSBundle(sourceDirectory, certificateName, privateKeyName, destination string, domains []string) error { resolvedSource, err := filepath.EvalSymlinks(sourceDirectory) if err != nil || resolvedSource != sourceDirectory { return ErrUnsafeSSLFile @@ -71,15 +98,15 @@ func ExportProjectTLS(root, destination string) error { } return data, nil } - certificate, err := read(filepath.Base(publicPath)) + certificate, err := read(certificateName) if err != nil { return err } - privateKey, err := read(filepath.Base(privatePath)) + privateKey, err := read(privateKeyName) if err != nil { return err } - if err := validateSSLMaterial(privateKey, certificate, nil, validationCertificateDomains(&runtime)); err != nil { + if err := validateSSLMaterial(privateKey, certificate, nil, domains); err != nil { return err } combined := append(append(append([]byte{}, certificate...), '\n'), privateKey...) diff --git a/backend/config/ssl_export_test.go b/backend/config/ssl_export_test.go index 26418d9..5e3dee1 100644 --- a/backend/config/ssl_export_test.go +++ b/backend/config/ssl_export_test.go @@ -10,6 +10,88 @@ import ( "time" ) +func TestPublishExternalTLSValidatesBeforeReplacingBundle(t *testing.T) { + if runtime.GOOS != "linux" { + if err := PublishExternalTLS(t.TempDir(), t.TempDir(), filepath.Join(t.TempDir(), "tls.pem")); err == nil { + t.Fatal("unsupported platform allowed publishing a private key") + } + t.Skip("Linux Nginx publishing requires POSIX permission checks") + } + root, source, output := t.TempDir(), t.TempDir(), t.TempDir() + for _, directory := range []string{source, output} { + if err := os.Chmod(directory, 0700); err != nil { + t.Fatal(err) + } + } + candidate := validRuntimeConfig() + candidate.Root.Domains = DomainConfig{Primary: "example.com", Web: "web.example.com", Accepted: []string{"example.com"}} + candidate.Root.HTTP = HTTPConfig{Port: 15184, TLSMode: HTTPSTLSModeProxy, TrustedProxyCIDRs: []string{"127.0.0.1/32"}} + candidate.SSL.TLS = SSLConfig{Management: SSLManagementWebServer, Mode: ManagedSSLModeUpload, CustomDirectory: "ssl"} + writeRuntimeFixture(t, root, candidate) + key, certificate := testManagedCertificate(t, "example.com", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048, "*.example.com") + write := func(name string, data []byte) { + t.Helper() + if err := os.WriteFile(filepath.Join(source, name), data, 0600); err != nil { + t.Fatal(err) + } + } + write("smail.crt", certificate) + write("smail.key", key) + destination := filepath.Join(output, "tls.pem") + if err := PublishExternalTLS(root, source, destination); err != nil { + t.Fatal(err) + } + bundle, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + if _, err := tls.X509KeyPair(bundle, bundle); err != nil { + t.Fatal(err) + } + before, err := os.Stat(destination) + if err != nil || before.Mode().Perm() != 0600 { + t.Fatal("published bundle must be private") + } + if err := PublishExternalTLS(root, source, destination); err != nil { + t.Fatal(err) + } + after, _ := os.Stat(destination) + if !os.SameFile(before, after) { + t.Fatal("unchanged certificate must not be rewritten") + } + assertRejected := func() { + t.Helper() + if err := PublishExternalTLS(root, source, destination); err == nil { + t.Fatal("invalid publication accepted") + } + preserved, err := os.ReadFile(destination) + if err != nil || !bytes.Equal(bundle, preserved) { + t.Fatal("failed publication replaced the previous bundle") + } + } + write("smail.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) + assertRejected() + write("smail.key", key) + write("smail.crt", certificate) + if err := os.Chmod(filepath.Join(source, "smail.key"), 0644); err != nil { + t.Fatal(err) + } + assertRejected() + if err := os.Chmod(filepath.Join(source, "smail.key"), 0600); err != nil { + t.Fatal(err) + } + if err := PublishExternalTLS(root, source, filepath.Join(source, "smail.crt")); err == nil { + t.Fatal("source overwrite allowed") + } + candidate.Root.HTTP.TLSMode = HTTPSTLSModeDirect + writeRuntimeFixture(t, root, candidate) + assertRejected() +} + func TestExportProjectTLSKeepsOldBundleOnInvalidInput(t *testing.T) { if runtime.GOOS != "linux" { if err := ExportProjectTLS(t.TempDir(), filepath.Join(t.TempDir(), "tls.pem")); err == nil { diff --git a/deploy/nginx/acme-renew.env.example b/deploy/nginx/acme-renew.env.example index 8aaa304..0828ea0 100644 --- a/deploy/nginx/acme-renew.env.example +++ b/deploy/nginx/acme-renew.env.example @@ -1,7 +1,7 @@ -# 仅在五个证书域名的公网 80 验证路径全部通过真实挑战后启用。 +# 首次签发及网页、邮件协议证书切换均确认后,再启用续期计时器。 # 正式文件为 /etc/smail/acme-renew.env,root 所有,权限 0600。 -# HTTP 模式复用 Nginx 的 /var/lib/smail-acme,不监听新端口,不停止服务。 -# 使用 DNS 模式时设为 dns,并在 acme-dns.env 中配置专用服务商凭据。 +# 使用 DNS 验证签发根域、泛域名及后台域名,不占用验证端口。 +# 专用服务商和最小权限凭据填写在 acme-dns.env,不能沿用示例或历史数据。 SMAIL_MAIL_DOMAIN= SMAIL_WEB_DOMAIN= -SMAIL_ACME_CHALLENGE=dns +SMAIL_ACME_EMAIL= diff --git a/deploy/nginx/smail-certificate-renew.service b/deploy/nginx/smail-certificate-renew.service index b095c4a..b75e62b 100644 --- a/deploy/nginx/smail-certificate-renew.service +++ b/deploy/nginx/smail-certificate-renew.service @@ -5,13 +5,19 @@ Wants=network-online.target [Service] Type=oneshot -ExecStart=/usr/local/libexec/smail-acme/renew_certificate.py -EnvironmentFile=-/etc/smail/acme-dns.env -EnvironmentFile=-/etc/smail/acme-renew.env +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} --domains ${SMAIL_WEB_DOMAIN} --filename smail --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 +StateDirectory=smail-acme-tls +StateDirectoryMode=0700 TimeoutStartSec=720 TimeoutStopSec=15 -MemoryHigh=96M -MemoryMax=128M +MemoryHigh=192M +MemoryMax=256M MemorySwapMax=0 CPUQuota=100% TasksMax=64 diff --git a/docs/HTTPS反向代理部署说明.md b/docs/HTTPS反向代理部署说明.md index 763d04a..5e36f5b 100644 --- a/docs/HTTPS反向代理部署说明.md +++ b/docs/HTTPS反向代理部署说明.md @@ -49,6 +49,30 @@ ## Nginx 配置示例 +### 使用 Go 工具维护外部泛域名证书 + +仓库中的 `deploy/nginx/smail-certificate-renew.service` 使用独立的 lego 命令行程序,并由 `smail-config` 校验、原子发布 Nginx 证书,不调用历史 Python 工具。此方式仍属于外部证书管理,不会重新启用 Smail 内置 HTTPS 或 ACME。 + +部署前必须由管理员输入本次域名、ACME 联系邮箱及 DNS 服务商凭据,不能复制历史实例的数据。示例环境文件保持空值;阿里云 DNS 凭据应限制在所需域名及验证记录操作范围内。 + +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。 +4. 执行下列发布命令。该命令校验证书有效期、域名、密钥匹配及文件权限,拒绝符号链接、不安全路径和源目录内覆盖;失败时保留原有 Nginx 合并证书。 + +```sh +/opt/smail/smail-config --root /opt/smail \ + --external-tls-dir /var/lib/smail-acme-tls/certificates \ + --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`;运行账号必须具备读取权限,不能通过开放私钥权限解决访问问题。 +6. 检查 Nginx 配置、平滑加载,并分别核验网页及邮件协议的公网证书。确认首次切换成功后,再安装并启用专用续期 service、timer;不要启用同机其他项目的全局续期任务,也不要同时保留旧的项目证书同步监听器。 + +专用计时器每天检查一次,并随机延迟;到期阈值为三十天,续期复用当前证书私钥。续期成功后重新校验并原子发布合并证书,再检查和加载 Nginx。Smail 从外部证书文件热加载,保持其无效新证书回退行为。不要在该任务运行期间另外手工调用同一状态目录的 lego。 + +计时器启动成功、当前证书未到期而跳过签发,都不能证明真实续期闭环通过。必须保留实际签发、发布和公网新证书加载的独立验收证据;首次签发也不能冒充到期自动续期。 + 下面示例将 Nginx 的 15184 本机代理端口替换为实际 Smail HTTP 端口。 ```nginx