This commit is contained in:
parent
dcfdc3be1a
commit
bc50eab30e
@ -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 {
|
||||||
|
|||||||
@ -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...)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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=
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user