补齐反代模式外部证书校验发布与Go续期部署模板
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-10-02 14:45:21 +08:00
parent dcfdc3be1a
commit bc50eab30e
6 changed files with 164 additions and 13 deletions

View File

@ -25,6 +25,8 @@ func run(args []string) error {
runtimeData := flags.Bool("runtime-data", false, "将运行数据迁到 data,保留当前五个配置文件") runtimeData := flags.Bool("runtime-data", false, "将运行数据迁到 data,保留当前五个配置文件")
historySource := flags.String("deployment-history", "", "停服复制完成后,从原部署导入配置历史并转换证书引用") historySource := flags.String("deployment-history", "", "停服复制完成后,从原部署导入配置历史并转换证书引用")
exportTLS := flags.String("export-project-tls", "", "将项目托管证书导出为本机 TLS 入口使用的合并 PEM 文件") 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 { if err := flags.Parse(args); err != nil {
return err return err
} }
@ -36,7 +38,7 @@ func run(args []string) error {
return fmt.Errorf("服务根目录无效") return fmt.Errorf("服务根目录无效")
} }
if *exportTLS != "" { if *exportTLS != "" {
if *apply || *runtimeData || *historySource != "" || !filepath.IsAbs(*exportTLS) { if *apply || *runtimeData || *historySource != "" || *publishTLS != "" || *externalTLSDirectory != "" || !filepath.IsAbs(*exportTLS) {
return fmt.Errorf("证书导出必须单独执行,目标必须是绝对路径") return fmt.Errorf("证书导出必须单独执行,目标必须是绝对路径")
} }
if err := config.ExportProjectTLS(absolute, filepath.Clean(*exportTLS)); err != nil { if err := config.ExportProjectTLS(absolute, filepath.Clean(*exportTLS)); err != nil {
@ -45,6 +47,16 @@ func run(args []string) error {
fmt.Println("项目证书同步检查完成。") fmt.Println("项目证书同步检查完成。")
return nil 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} options := config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true}
if *historySource != "" { if *historySource != "" {
if !*apply || *runtimeData { if !*apply || *runtimeData {

View File

@ -34,6 +34,33 @@ func ExportProjectTLS(root, destination string) error {
return ErrInvalidSSLReference return ErrInvalidSSLReference
} }
sourceDirectory := filepath.Join(root, "ssl") 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) resolvedSource, err := filepath.EvalSymlinks(sourceDirectory)
if err != nil || resolvedSource != sourceDirectory { if err != nil || resolvedSource != sourceDirectory {
return ErrUnsafeSSLFile return ErrUnsafeSSLFile
@ -71,15 +98,15 @@ func ExportProjectTLS(root, destination string) error {
} }
return data, nil return data, nil
} }
certificate, err := read(filepath.Base(publicPath)) certificate, err := read(certificateName)
if err != nil { if err != nil {
return err return err
} }
privateKey, err := read(filepath.Base(privatePath)) privateKey, err := read(privateKeyName)
if err != nil { if err != nil {
return err return err
} }
if err := validateSSLMaterial(privateKey, certificate, nil, validationCertificateDomains(&runtime)); err != nil { if err := validateSSLMaterial(privateKey, certificate, nil, domains); err != nil {
return err return err
} }
combined := append(append(append([]byte{}, certificate...), '\n'), privateKey...) combined := append(append(append([]byte{}, certificate...), '\n'), privateKey...)

View File

@ -10,6 +10,88 @@ import (
"time" "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) { func TestExportProjectTLSKeepsOldBundleOnInvalidInput(t *testing.T) {
if runtime.GOOS != "linux" { if runtime.GOOS != "linux" {
if err := ExportProjectTLS(t.TempDir(), filepath.Join(t.TempDir(), "tls.pem")); err == nil { if err := ExportProjectTLS(t.TempDir(), filepath.Join(t.TempDir(), "tls.pem")); err == nil {

View File

@ -1,7 +1,7 @@
# 仅在五个证书域名的公网 80 验证路径全部通过真实挑战后启用。 # 首次签发及网页、邮件协议证书切换均确认后,再启用续期计时器。
# 正式文件为 /etc/smail/acme-renew.env,root 所有,权限 0600。 # 正式文件为 /etc/smail/acme-renew.env,root 所有,权限 0600。
# HTTP 模式复用 Nginx 的 /var/lib/smail-acme,不监听新端口,不停止服务。 # 使用 DNS 验证签发根域、泛域名及后台域名,不占用验证端口。
# 使用 DNS 模式时设为 dns,并在 acme-dns.env 中配置专用服务商凭据。 # 专用服务商和最小权限凭据填写在 acme-dns.env,不能沿用示例或历史数据。
SMAIL_MAIL_DOMAIN= SMAIL_MAIL_DOMAIN=
SMAIL_WEB_DOMAIN= SMAIL_WEB_DOMAIN=
SMAIL_ACME_CHALLENGE=dns SMAIL_ACME_EMAIL=

View File

@ -5,13 +5,19 @@ Wants=network-online.target
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/local/libexec/smail-acme/renew_certificate.py EnvironmentFile=/etc/smail/acme-dns.env
EnvironmentFile=-/etc/smail/acme-dns.env EnvironmentFile=/etc/smail/acme-renew.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 TimeoutStartSec=720
TimeoutStopSec=15 TimeoutStopSec=15
MemoryHigh=96M MemoryHigh=192M
MemoryMax=128M MemoryMax=256M
MemorySwapMax=0 MemorySwapMax=0
CPUQuota=100% CPUQuota=100%
TasksMax=64 TasksMax=64

View File

@ -49,6 +49,30 @@
## Nginx 配置示例 ## 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 的 15184 本机代理端口替换为实际 Smail HTTP 端口。
```nginx ```nginx