From a045a0cd1713fbd6294c30e5b7835b197f3380d3 Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 8 Sep 2026 20:34:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E9=83=A8=E7=BD=B2=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E8=87=AA=E5=8A=A8=E5=BD=92=E6=A1=A3=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E4=B8=8E=E8=AF=81=E4=B9=A6=E5=8F=8C=E7=89=88=E6=9C=AC=E4=BF=9D?= =?UTF-8?q?=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/deployment_state.go | 20 +- backend/config/manager_v2.go | 16 ++ backend/config/pending.go | 7 +- backend/config/runtime_manager.go | 4 + backend/config/ssl_commit.go | 2 + backend/config/ssl_custom.go | 2 + backend/config/ssl_lease_unix.go | 9 + backend/config/ssl_lease_windows.go | 24 ++ backend/config/ssl_retention.go | 240 ++++++++++++++++++ backend/config/ssl_storage.go | 56 ++++- backend/internal/app/app.go | 1 + backend/listen/cron_server/ssl_update.go | 1 + backend/services/externalemail/vault.go | 10 +- backend/services/setup/ssl/ssl.go | 2 + backend/utils/credential/encryption.go | 9 +- backend/utils/runtimepath/business_keys.go | 137 ++++++++++ backend/utils/runtimepath/deployment.go | 24 +- backend/utils/runtimepath/layout.go | 9 +- deploy/runtime-config.mjs | 26 +- docs/部署后自动归档与证书保留.md | 95 +++++++ test/backend/data_paths_test.go | 2 + test/backend/deployment_state_test.go | 82 ++++++ test/backend/runtime_layout_test.go | 46 ++++ test/backend/ssl_retention_test.go | 250 +++++++++++++++++++ test/deployment/verify_ssl_startup.py | 120 +++++++++ test/frontend/runtime-deployment.test.mjs | 38 ++- test/run.mjs | 1 + 27 files changed, 1209 insertions(+), 24 deletions(-) create mode 100644 backend/config/ssl_lease_unix.go create mode 100644 backend/config/ssl_lease_windows.go create mode 100644 backend/config/ssl_retention.go create mode 100644 backend/utils/runtimepath/business_keys.go create mode 100644 docs/部署后自动归档与证书保留.md create mode 100644 test/backend/ssl_retention_test.go create mode 100644 test/deployment/verify_ssl_startup.py diff --git a/backend/config/deployment_state.go b/backend/config/deployment_state.go index 7be7450..2ec81e7 100644 --- a/backend/config/deployment_state.go +++ b/backend/config/deployment_state.go @@ -9,8 +9,12 @@ import ( "git.pcux.cn/xuans/Smail/utils/runtimepath" ) -// InitializeDeployment 仅对全新安装启用新布局,不触碰已有部署或生成替代密钥。 +// InitializeDeployment 为全新部署创建状态库,并自动归档已采用 ssl 布局的原有业务密钥。 +// 更旧的 config/data 布局仍需完整迁移,不能只搬密钥而切断证书或数据库引用。 func InitializeDeployment(root string) error { + if runtimepath.DeploymentLayout(root) { + return initializeDeploymentKeys(root) + } for _, name := range []string{"config/config.json", "data/credential.key", "config/credential.key"} { if _, err := os.Lstat(filepath.Join(root, filepath.FromSlash(name))); err == nil { return nil @@ -22,7 +26,19 @@ func InitializeDeployment(root string) error { if err != nil { return err } - return db.Close() + if err := db.Close(); err != nil { + return err + } + return initializeDeploymentKeys(root) +} + +func initializeDeploymentKeys(root string) error { + unlock, err := NewTransactionStore(root, RuntimeValidationOptions{}).lockState() + if err != nil { + return err + } + defer unlock() + return runtimepath.PrepareDeploymentKeys(root) } // ImportDeploymentHistory 在停服迁移期间导入旧历史原始字节,原目录保持不动。 diff --git a/backend/config/manager_v2.go b/backend/config/manager_v2.go index d15bcf9..cf006c3 100644 --- a/backend/config/manager_v2.go +++ b/backend/config/manager_v2.go @@ -224,10 +224,15 @@ func (m *ConfigManager) Load(ctx context.Context) error { } now := runtime.LoadedAt m.mu.Lock() + previousPending := m.pending m.current = runtime m.pending = nil m.status = ConfigStatus{SetupState: runtime.SetupState, ActiveGeneration: runtime.Generation, LastValidAt: &now} m.mu.Unlock() + if previousPending != nil { + releaseManagedSSL(m.loader.root, previousPending.Candidate.SSL.TLS) + } + maintainManagedSSL(m.loader.root, runtime.SSL.TLS) return nil } @@ -458,6 +463,7 @@ func (m *ConfigManager) apply(ctx context.Context, generation uint64, actor Audi pendingRestart := append([]ServiceName(nil), m.status.PendingRestart...) m.status = ConfigStatus{SetupState: candidate.SetupState, ActiveGeneration: candidate.Generation, LastValidAt: &now, PendingRestart: pendingRestart} m.mu.Unlock() + maintainManagedSSL(m.loader.root, candidate.SSL.TLS, current.SSL.TLS) if err := m.writeAudit(ctx, ConfigAuditEvent{ Operation: ConfigOperationUpdate, Section: pending.Section, @@ -514,6 +520,9 @@ func (m *ConfigManager) PreviewRollback(ctx context.Context, generation uint64, if err := ValidateRuntimeSecrets(m.vault, backup); err != nil { return nil, err } + if err := validateRollbackSSL(m.loader.root, backup); err != nil { + return nil, err + } return &RollbackPreview{ CurrentGeneration: currentGeneration, TargetGeneration: generation, @@ -552,6 +561,9 @@ func (m *ConfigManager) IssueRollbackTicket(ctx context.Context, generation uint if err := ValidateRuntimeSecrets(m.vault, backup); err != nil { return "", err } + if err := validateRollbackSSL(m.loader.root, backup); err != nil { + return "", err + } token, err := newRollbackTicketID() if err != nil { return "", err @@ -671,6 +683,9 @@ func (m *ConfigManager) rollbackWithState(ctx context.Context, generation uint64 if err := ValidateRuntimeSecrets(m.vault, candidate); err != nil { return nil, false, err } + if err := validateRollbackSSL(m.loader.root, candidate); err != nil { + return nil, false, err + } setCandidateGeneration(&candidate, current.Generation+1) candidate.Root.UpdatedAt = time.Now().UTC() if err := ValidateRuntimeConfig(candidate, m.options); err != nil { @@ -705,6 +720,7 @@ func (m *ConfigManager) rollbackWithState(ctx context.Context, generation uint64 PendingRestart: []ServiceName{ServiceDatabase, ServiceHTTP, ServiceSMTP, ServiceEmail, ServiceSSL, ServicePlugins}, } m.mu.Unlock() + maintainManagedSSL(m.loader.root, candidate.SSL.TLS, current.SSL.TLS) if err := m.writeAudit(ctx, ConfigAuditEvent{ Operation: ConfigOperationRollback, Section: SectionRoot, diff --git a/backend/config/pending.go b/backend/config/pending.go index 4e7c151..8b6c853 100644 --- a/backend/config/pending.go +++ b/backend/config/pending.go @@ -78,7 +78,7 @@ func (m *ConfigManager) ManagePending(ctx context.Context, generation uint64, id return err } m.mu.Lock() - defer m.mu.Unlock() + activeSSL := m.current.SSL.TLS if cancel { m.pending = nil m.status.PendingRestart = append([]ServiceName(nil), pending.PreviousRestart...) @@ -86,5 +86,10 @@ func (m *ConfigManager) ManagePending(ctx context.Context, generation uint64, id m.pending.Actor = actor m.pending.Confirmation = confirmation } + m.mu.Unlock() + if cancel { + releaseManagedSSL(m.loader.root, pending.Candidate.SSL.TLS) + maintainManagedSSL(m.loader.root, activeSSL) + } return nil } diff --git a/backend/config/runtime_manager.go b/backend/config/runtime_manager.go index 2a0fa9b..c4a5ac6 100644 --- a/backend/config/runtime_manager.go +++ b/backend/config/runtime_manager.go @@ -10,8 +10,12 @@ var runtimeManager struct { // SetRuntimeManager 注入当前进程使用的配置管理器。 func SetRuntimeManager(manager *ConfigManager) { runtimeManager.Lock() + previous := runtimeManager.value runtimeManager.value = manager runtimeManager.Unlock() + if previous != nil && previous != manager { + previous.releasePendingSSL() + } } // CurrentManager 返回当前配置管理器;未完成分区配置初始化时返回 nil。 diff --git a/backend/config/ssl_commit.go b/backend/config/ssl_commit.go index 3ce387d..0aab0f0 100644 --- a/backend/config/ssl_commit.go +++ b/backend/config/ssl_commit.go @@ -54,6 +54,7 @@ func WriteSSLConfig(expected, next *Config) error { return err } *next = merged + maintainManagedSSL(ROOT_PATH, next.SSL, expected.SSL) return nil } manager := CurrentManager() @@ -134,6 +135,7 @@ func WriteSSLConfig(expected, next *Config) error { // 兼容快照与本次已提交候选一致,不再静默忽略提交后的磁盘读取失败。 *next = *committed PublishConfigSnapshot(committed) + maintainManagedSSL(ROOT_PATH, candidate.SSL.TLS, current.SSL.TLS) return manager.writeAudit(context.Background(), ConfigAuditEvent{ Operation: ConfigOperationUpdate, Section: SectionSSL, ActorID: "system", ActorRole: "system", FromGeneration: current.Generation, ToGeneration: candidate.Generation, diff --git a/backend/config/ssl_custom.go b/backend/config/ssl_custom.go index 3178d9f..a25551e 100644 --- a/backend/config/ssl_custom.go +++ b/backend/config/ssl_custom.go @@ -189,6 +189,8 @@ func SSLCertificateDomains(primary, web string, accepted []string) []string { func SSLValidationIssue(err error) (PublicIssue, bool) { issue := PublicIssue{Code: "SSL_INVALID"} switch { + case errors.Is(err, ErrSSLHistoryUnavailable): + issue.Message = "历史证书已清理或不可用,请重新导入有效证书,不能回滚此配置" case errors.Is(err, ErrSSLFileUnavailable): issue.Message = "证书或私钥文件不存在或无法读取,请检查服务器路径和运行用户权限" case errors.Is(err, ErrSSLCertificateMismatch): diff --git a/backend/config/ssl_lease_unix.go b/backend/config/ssl_lease_unix.go new file mode 100644 index 0000000..99db724 --- /dev/null +++ b/backend/config/ssl_lease_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package config + +import "os" + +func openSSLLease(path string) (*os.File, error) { + return os.OpenFile(path, os.O_RDWR, 0600) +} diff --git a/backend/config/ssl_lease_windows.go b/backend/config/ssl_lease_windows.go new file mode 100644 index 0000000..5f17ed6 --- /dev/null +++ b/backend/config/ssl_lease_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package config + +import ( + "os" + + "golang.org/x/sys/windows" +) + +// 共享删除权限仅避免旧版停服迁移被文件句柄阻挡;是否清理由独占租约决定。 +func openSSLLease(path string) (*os.File, error) { + name, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile(name, windows.GENERIC_READ|windows.GENERIC_WRITE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(handle), path), nil +} diff --git a/backend/config/ssl_retention.go b/backend/config/ssl_retention.go new file mode 100644 index 0000000..da2bfe1 --- /dev/null +++ b/backend/config/ssl_retention.go @@ -0,0 +1,240 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "git.pcux.cn/xuans/Smail/utils/runtimepath" + log "github.com/sirupsen/logrus" +) + +// 清理与写入共用互斥锁;暂存材料从创建到提交或取消期间始终受到保护。 +var managedSSLStorage = struct { + sync.Mutex + staged map[string]func() +}{staged: make(map[string]func())} + +type retainedSSLVersion struct { + directory string + modified time.Time +} + +func releaseManagedSSL(root string, ssl SSLConfig) { + managedSSLStorage.Lock() + defer managedSSLStorage.Unlock() + releaseManagedSSLLocked(root, ssl) +} + +func releaseManagedSSLLocked(root string, ssl SSLConfig) { + if ssl.CertificateRef == "" { + return + } + path, err := filepath.Abs(filepath.Join(root, filepath.FromSlash(runtimepath.SSLReference(root, ssl.CertificateRef)))) + if err == nil { + directory := filepath.Dir(path) + if release := managedSSLStorage.staged[directory]; release != nil { + release() + delete(managedSSLStorage.staged, directory) + } + } +} + +// ReleaseStagedSSL 仅解除暂存保护,不立即删除;失败操作的残留由下一轮维护处理。 +func ReleaseStagedSSL(reference ManagedSSLReference) { + releaseManagedSSL(ROOT_PATH, SSLConfig{CertificateRef: reference.CertificateRef}) +} + +// 管理器停止或被替换时解除遗留租约;不删除材料,已提交引用仍由磁盘配置保护。 +func (m *ConfigManager) releasePendingSSL() { + m.writeMu.Lock() + defer m.writeMu.Unlock() + m.mu.Lock() + pending := m.pending + m.pending = nil + m.mu.Unlock() + if pending != nil && m.loader != nil { + releaseManagedSSL(m.loader.root, pending.Candidate.SSL.TLS) + } +} + +// maintainManagedSSL 必须在配置写锁内调用;失败只告警,不能把已保存配置误报为未保存。 +func maintainManagedSSL(root string, active SSLConfig, previous ...SSLConfig) { + managedSSLStorage.Lock() + defer managedSSLStorage.Unlock() + // 与其他进程的配置事务互斥;锁定后再读取生效引用及恢复状态。 + if runtimepath.DeploymentLayout(root) { + unlock, err := NewTransactionStore(root, RuntimeValidationOptions{}).lockState() + if err != nil { + log.Warn("配置事务正在运行,证书历史清理将在下次维护时重试") + return + } + defer unlock() + } + releaseManagedSSLLocked(root, active) + if err := pruneManagedSSLLocked(root, append([]SSLConfig{active}, previous...)); err != nil { + log.Warn("证书历史自动清理未完成,已保留现场,将在下次维护时重试") + } +} + +// MaintainManagedSSLHistory 用于启动完成后及定时维护;全新安装无证书时不做删除。 +func MaintainManagedSSLHistory() { + if manager := CurrentManager(); manager != nil { + manager.writeMu.Lock() + defer manager.writeMu.Unlock() + manager.mu.RLock() + active := manager.current.SSL.TLS + var pending []SSLConfig + if manager.pending != nil { + pending = append(pending, manager.pending.Candidate.SSL.TLS) + } + manager.mu.RUnlock() + maintainManagedSSL(manager.loader.root, active, pending...) + return + } + sslCommitMu.Lock() + defer sslCommitMu.Unlock() + if cfg := CurrentConfig(); cfg != nil { + maintainManagedSSL(ROOT_PATH, cfg.SSL) + } +} + +func pruneManagedSSLLocked(root string, protected []SSLConfig) error { + if len(protected) == 0 || protected[0].CertificateRef == "" { + return nil + } + // 恢复事务可能仍需要旧材料;恢复未完成时不能清理任何版本。 + if runtimepath.DeploymentLayout(root) { + records, err := NewTransactionStore(root, RuntimeValidationOptions{HasSuperAdmin: true}).stateRecords("transaction") + if err != nil || len(records) != 0 { + return errors.Join(ErrConfigRecoveryRequired, err) + } + } else if entries, err := os.ReadDir(filepath.Join(root, "data", "transaction")); err != nil && !os.IsNotExist(err) || len(entries) > 0 { + return ErrConfigRecoveryRequired + } + keep := make(map[string]bool) + for _, ssl := range protected { + if ssl.CertificateRef == "" { + continue + } + reference, err := resolveManagedSSLPathsAtRoot(root, ssl) + if err != nil { + return err + } + keep[filepath.Dir(reference.CertificatePath)] = true + } + // 磁盘引用也必须保留,避免其他管理器实例或已提交未发布的候选被删除。 + data, err := os.ReadFile(filepath.Join(root, "config", "ssl.json")) + if err == nil { + var section SSLSectionConfig + if err := ParseStrict(data, §ion); err != nil { + return err + } + if section.TLS.CertificateRef != "" { + reference, err := resolveManagedSSLPathsAtRoot(root, section.TLS) + if err != nil { + return err + } + keep[filepath.Dir(reference.CertificatePath)] = true + } + } else if !os.IsNotExist(err) { + return err + } + var versions []retainedSSLVersion + // 持有租约直到整轮清理结束,避免检查解锁后与另一个进程的导入操作竞态。 + var leases []func() + defer func() { + for _, release := range leases { + release() + } + }() + for _, mode := range []string{ManagedSSLModeDefault, ManagedSSLModeUpload} { + directory, err := managedSSLRootPathAtRoot(root, mode) + if err != nil { + return err + } + entries, err := os.ReadDir(directory) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + resolved, err := filepath.EvalSymlinks(directory) + if err != nil || filepath.Clean(resolved) != filepath.Clean(directory) { + return ErrUnsafeSSLFile + } + for _, entry := range entries { + name := entry.Name() + // 只处理程序生成的完整版本;未知目录、候选和其他用户文件一律不删。 + known := name == "current" || validSSLVersion(name) || strings.HasPrefix(name, ".previous-") && validSSLVersion("version-"+strings.TrimPrefix(name, ".previous-")) + if !known { + continue + } + path := filepath.Join(directory, name) + info, err := os.Lstat(path) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return ErrUnsafeSSLFile + } + if managedSSLStorage.staged[path] != nil { + keep[path] = true + } + if keep[path] { + versions = append(versions, retainedSSLVersion{directory: path, modified: info.ModTime()}) + continue + } + // 其他进程正在导入的版本持有私钥文件的独占租约,不依赖本进程内存判断。 + lease, err := openSSLLease(filepath.Join(path, "private.key")) + if err != nil { + keep[path] = true + } else { + unlock, err := lockStateFile(lease) + if err != nil { + keep[path] = true + lease.Close() + } else { + leases = append(leases, func() { unlock(); lease.Close() }) + } + } + versions = append(versions, retainedSSLVersion{directory: path, modified: info.ModTime()}) + } + } + sort.Slice(versions, func(i, j int) bool { + if versions[i].modified.Equal(versions[j].modified) { + return versions[i].directory > versions[j].directory + } + return versions[i].modified.After(versions[j].modified) + }) + // 两个模式合计保留两份;正在生效和暂存的版本优先,操作结束后收敛到两份。 + for _, version := range versions { + if len(keep) >= 2 { + break + } + keep[version.directory] = true + } + var result error + for _, version := range versions { + if !keep[version.directory] { + result = errors.Join(result, removeManagedSSLDirectory(version.directory)) + } + } + return result +} + +// 回滚前必须检查实际证书,不能提交已经被保留策略淘汰的历史引用。 +func validateRollbackSSL(root string, candidate RuntimeConfig) error { + ssl := candidate.SSL.TLS + if ssl.CertificateRef == "" && ssl.PrivateKeyRef == "" { + return nil + } + if _, err := resolveManagedSSLReferencesAtRoot(root, ssl); err != nil { + return ErrSSLHistoryUnavailable + } + return nil +} + +var ErrSSLHistoryUnavailable = errors.New("历史证书已清理或不可用,无法回滚此配置,请重新导入有效证书") diff --git a/backend/config/ssl_storage.go b/backend/config/ssl_storage.go index d51b441..a026999 100644 --- a/backend/config/ssl_storage.go +++ b/backend/config/ssl_storage.go @@ -104,6 +104,15 @@ func ImportManagedSSL(mode string, privateKeyPEM, certificatePEM, issuerPEM []by } func importManagedSSLAtRoot(root string, mode string, privateKeyPEM, certificatePEM, issuerPEM []byte, domains []string) (ManagedSSLReference, error) { + managedSSLStorage.Lock() + defer managedSSLStorage.Unlock() + if runtimepath.DeploymentLayout(root) { + unlock, err := NewTransactionStore(root, RuntimeValidationOptions{}).lockState() + if err != nil { + return ManagedSSLReference{}, err + } + defer unlock() + } if !validManagedSSLMode(mode) { return ManagedSSLReference{}, ErrInvalidSSLMode } @@ -208,6 +217,18 @@ func ResolveManagedSSLReferences(ssl SSLConfig) (ManagedSSLReference, error) { } func resolveManagedSSLReferencesAtRoot(root string, ssl SSLConfig) (ManagedSSLReference, error) { + reference, err := resolveManagedSSLPathsAtRoot(root, ssl) + if err != nil { + return ManagedSSLReference{}, err + } + if err := validateManagedSSLFiles(reference.CertificatePath, reference.PrivateKeyPath); err != nil { + return ManagedSSLReference{}, err + } + return reference, nil +} + +// 路径解析与材料校验分离:历史清理保护过期证书的引用,但运行和回滚仍须完整校验。 +func resolveManagedSSLPathsAtRoot(root string, ssl SSLConfig) (ManagedSSLReference, error) { ssl = normalizeSSLDataReferences(ssl) if !validManagedSSLMode(ssl.Mode) { return ManagedSSLReference{}, ErrInvalidSSLMode @@ -230,9 +251,6 @@ func resolveManagedSSLReferencesAtRoot(root string, ssl SSLConfig) (ManagedSSLRe if filepath.Dir(certificatePath) != filepath.Dir(privateKeyPath) { return ManagedSSLReference{}, ErrInvalidSSLReference } - if err := validateManagedSSLFiles(certificatePath, privateKeyPath); err != nil { - return ManagedSSLReference{}, err - } return ManagedSSLReference{ Mode: ssl.Mode, CertificateRef: ssl.CertificateRef, @@ -450,6 +468,15 @@ func StageManagedSSL(mode string, privateKeyPEM, certificatePEM, issuerPEM []byt } func stageManagedSSLAtRoot(root string, mode string, privateKeyPEM, certificatePEM, issuerPEM []byte, domains []string) (ManagedSSLReference, error) { + managedSSLStorage.Lock() + defer managedSSLStorage.Unlock() + if runtimepath.DeploymentLayout(root) { + unlock, err := NewTransactionStore(root, RuntimeValidationOptions{}).lockState() + if err != nil { + return ManagedSSLReference{}, err + } + defer unlock() + } if err := validateSSLMaterial(privateKeyPEM, certificatePEM, issuerPEM, domains); err != nil { return ManagedSSLReference{}, err } @@ -499,6 +526,16 @@ func stageManagedSSLAtRoot(root string, mode string, privateKeyPEM, certificateP if err != nil { return ManagedSSLReference{}, err } + lease, err := openSSLLease(privatePath) + if err != nil { + return ManagedSSLReference{}, err + } + unlock, err := lockStateFile(lease) + if err != nil { + lease.Close() + return ManagedSSLReference{}, err + } + managedSSLStorage.staged[directory] = func() { unlock(); lease.Close() } return ManagedSSLReference{Mode: mode, CertificateRef: filepath.ToSlash(publicRef), PrivateKeyRef: filepath.ToSlash(privateRef), CertificatePath: publicPath, PrivateKeyPath: privatePath}, nil } @@ -589,6 +626,7 @@ func removeManagedSSLDirectory(directory string) error { if err != nil { return err } + // 删除前完整预检,遇到未知文件不能先删掉半套证书。 for _, entry := range entries { if entry.Name() != "private.key" && entry.Name() != "public.crt" && entry.Name() != "issuer.crt" { return ErrUnsafeSSLFile @@ -598,10 +636,20 @@ func removeManagedSSLDirectory(directory string) error { if statErr != nil || entryInfo.Mode()&os.ModeSymlink != 0 || !entryInfo.Mode().IsRegular() { return ErrUnsafeSSLFile } - if err := os.Remove(entryPath); err != nil { + } + storage, err := os.OpenRoot(directory) + if err != nil { + return err + } + for _, entry := range entries { + if err := storage.Remove(entry.Name()); err != nil { + storage.Close() return err } } + if err := storage.Close(); err != nil { + return err + } return os.Remove(directory) } diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index f361ba7..e92f726 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -23,6 +23,7 @@ func Run() error { if err := config.Init(); err != nil { return err } + config.MaintainManagedSSLHistory() build := buildinfo.Current() if build.Version == "dev" { diff --git a/backend/listen/cron_server/ssl_update.go b/backend/listen/cron_server/ssl_update.go index ef92725..f4cabb7 100644 --- a/backend/listen/cron_server/ssl_update.go +++ b/backend/listen/cron_server/ssl_update.go @@ -52,6 +52,7 @@ func sslCheck() { // 每轮读取最新策略,切换反代模式后不再等待 HTTP 挑战或执行自动续期。 func maintainSSL() { + config.MaintainManagedSSLHistory() cfg := config.CurrentConfig() if cfg == nil || !cfg.IsSetupReady() { return diff --git a/backend/services/externalemail/vault.go b/backend/services/externalemail/vault.go index 6fa10cb..c0442ca 100644 --- a/backend/services/externalemail/vault.go +++ b/backend/services/externalemail/vault.go @@ -48,15 +48,19 @@ func NewVault(root string) (*Vault, error) { return nil, ErrVaultUnavailable } } - configDir, err := runtimepath.SecureDirectory(root, filepath.Base(runtimepath.KeyDirectory(root))) + encryptionDirectory, err := runtimepath.SecureKeyDirectory(root, "external-email-encryption.key") if err != nil { return nil, ErrVaultUnavailable } - encryptionKey, err := loadKey(filepath.Join(configDir, "external-email-encryption.key")) + fingerprintDirectory, err := runtimepath.SecureKeyDirectory(root, "external-email-fingerprint.key") if err != nil { return nil, ErrVaultUnavailable } - fingerprintKey, err := loadKey(filepath.Join(configDir, "external-email-fingerprint.key")) + encryptionKey, err := loadKey(filepath.Join(encryptionDirectory, "external-email-encryption.key")) + if err != nil { + return nil, ErrVaultUnavailable + } + fingerprintKey, err := loadKey(filepath.Join(fingerprintDirectory, "external-email-fingerprint.key")) if err != nil { return nil, ErrVaultUnavailable } diff --git a/backend/services/setup/ssl/ssl.go b/backend/services/setup/ssl/ssl.go index 629dfa0..d96a232 100644 --- a/backend/services/setup/ssl/ssl.go +++ b/backend/services/setup/ssl/ssl.go @@ -151,6 +151,7 @@ func SetSSL(sslType, priKey, crtKey string) error { return normalizeUserCertificateImportError(err) } applyManagedSSLReference(cfg, reference) + defer config.ReleaseStagedSSL(reference) // 导入邮件服务证书不能重新启用反代模式下的内置 HTTPS。 if cfg.UsesProxyHTTPS() { cfg.HttpsEnabled = 2 @@ -303,6 +304,7 @@ func writeCertificateFiles(cfg *config.Config, privateKey, certificate, issuer [ return err } applyManagedSSLReference(&next, reference) + defer config.ReleaseStagedSSL(reference) if err := config.WriteSSLConfig(cfg, &next); err != nil { return errors.Wrap(err) } diff --git a/backend/utils/credential/encryption.go b/backend/utils/credential/encryption.go index f59d526..a57aff5 100644 --- a/backend/utils/credential/encryption.go +++ b/backend/utils/credential/encryption.go @@ -86,8 +86,8 @@ func loadOrCreateKey(root string) ([]byte, error) { if !os.IsNotExist(err) { return nil, err } - directory := runtimepath.KeyDirectory(root) - if err := os.MkdirAll(directory, 0700); err != nil { + directory, err := runtimepath.SecureKeyDirectory(root, "credential.key") + if err != nil { return nil, err } if err := validateKeyDirectory(directory); err != nil { @@ -169,6 +169,11 @@ func readKey(root string) ([]byte, error) { return nil, err } directory := runtimepath.KeyDirectory(root) + if runtimepath.DeploymentLayout(root) { + if err := validateKeyDirectory(filepath.Join(root, "ssl")); err != nil { + return nil, err + } + } if err := validateKeyDirectory(directory); err != nil { return nil, err } diff --git a/backend/utils/runtimepath/business_keys.go b/backend/utils/runtimepath/business_keys.go new file mode 100644 index 0000000..a4307ee --- /dev/null +++ b/backend/utils/runtimepath/business_keys.go @@ -0,0 +1,137 @@ +package runtimepath + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + + "git.pcux.cn/xuans/Smail/utils/atomicfile" +) + +// BusinessKeyNames 返回固定业务密钥清单,不能由外部输入扩展迁移范围。 +func BusinessKeyNames() []string { + return []string{"credential.key", "external-email-encryption.key", "external-email-fingerprint.key"} +} + +func businessKeyFolder(name string) string { + switch name { + case "credential.key": + return "credentials" + case "external-email-encryption.key": + return "external-email-encryption" + case "external-email-fingerprint.key": + return "external-email-fingerprint" + default: + panic("unknown business key") + } +} + +func groupedKeyPath(root, name string) string { + return filepath.Join(root, "ssl", businessKeyFolder(name), name) +} + +// KeyPath 兼容尚未升级的部署;正式启动完成归档后只使用业务子目录。 +func KeyPath(root, name string) string { + folder := businessKeyFolder(name) + if !DeploymentLayout(root) { + return filepath.Join(root, "data", name) + } + if _, err := os.Lstat(filepath.Join(root, "ssl", name)); err == nil { + return filepath.Join(root, "ssl", name) + } + if _, err := os.Lstat(filepath.Join(root, "ssl", folder)); !os.IsNotExist(err) { + return groupedKeyPath(root, name) + } + return filepath.Join(root, "ssl", name) +} + +// SecureKeyDirectory 逐层验证目录,不能通过父目录链接写入外部位置。 +func SecureKeyDirectory(root, name string) (string, error) { + if err := CheckKeyLayout(root); err != nil { + return "", err + } + directory := filepath.Dir(KeyPath(root, name)) + base := "data" + if DeploymentLayout(root) { + base = "ssl" + } + parent, err := SecureDirectory(root, base) + if err != nil { + return "", err + } + if filepath.Clean(directory) == filepath.Clean(parent) { + return parent, nil + } + return SecureDirectory(parent, businessKeyFolder(name)) +} + +// PrepareDeploymentKeys 由主程序启动入口在独占状态锁内调用,不依赖安装脚本。 +// 每次重命名保留原字节;中断后的混合位置可在下次启动继续迁移。 +func PrepareDeploymentKeys(root string) error { + if !DeploymentLayout(root) { + return nil + } + if err := CheckReady(root); err != nil { + return err + } + var moves []Move + for _, name := range BusinessKeyNames() { + source := KeyPath(root, name) + info, err := os.Lstat(source) + if os.IsNotExist(err) { + continue + } + if err != nil || !info.Mode().IsRegular() || info.Size() != 32 { + return fmt.Errorf("业务密钥无效,拒绝迁移:%s", name) + } + if err := safeDirectory(filepath.Join(root, "ssl")); err != nil { + return err + } + if err := safeDirectory(filepath.Dir(source)); err != nil { + return err + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0077 != 0 { + return errors.New("业务密钥权限过于宽松,拒绝迁移") + } + if source != groupedKeyPath(root, name) { + moves = append(moves, Move{Source: source, Target: groupedKeyPath(root, name)}) + } + } + ssl, err := SecureDirectory(root, "ssl") + if err != nil { + return err + } + // 先检查所有目标,不能移动第一把密钥后才发现另一业务目录不安全。 + for _, folder := range []string{"credentials", "external-email-encryption", "external-email-fingerprint", "dkim", "managed"} { + if err := safeDirectory(filepath.Join(ssl, folder)); err != nil && !os.IsNotExist(err) { + return err + } + } + for _, folder := range []string{"config", "log"} { + if _, err := SecureDirectory(root, folder); err != nil { + return err + } + } + for _, folder := range []string{"credentials", "external-email-encryption", "external-email-fingerprint", "dkim", "managed"} { + if _, err := SecureDirectory(ssl, folder); err != nil { + return err + } + } + for _, move := range moves { + if _, err := os.Lstat(move.Target); !os.IsNotExist(err) { + return errors.New("密钥迁移目标已存在,拒绝覆盖") + } + if err := os.Rename(move.Source, move.Target); err != nil { + return fmt.Errorf("业务密钥归档未完成,下次启动将继续:%w", err) + } + if err := atomicfile.SyncDirectory(filepath.Dir(move.Target)); err != nil { + return err + } + if err := atomicfile.SyncDirectory(ssl); err != nil { + return err + } + } + return CheckKeyLayout(root) +} diff --git a/backend/utils/runtimepath/deployment.go b/backend/utils/runtimepath/deployment.go index f16acbe..808b277 100644 --- a/backend/utils/runtimepath/deployment.go +++ b/backend/utils/runtimepath/deployment.go @@ -4,17 +4,26 @@ import ( "errors" "os" "path/filepath" + "strings" ) // CheckKeyLayout 拒绝同时存在新旧密钥,避免误用另一套加密材料。 func CheckKeyLayout(root string) error { legacy, modern := false, false - for _, name := range []string{"credential.key", "external-email-encryption.key", "external-email-fingerprint.key"} { + for _, name := range BusinessKeyNames() { if _, err := os.Lstat(filepath.Join(root, "data", name)); err == nil { legacy = true } else if !os.IsNotExist(err) { return err } + if _, err := os.Lstat(groupedKeyPath(root, name)); err == nil { + modern = true + if _, err := os.Lstat(filepath.Join(root, "ssl", name)); !os.IsNotExist(err) { + return errors.New("密钥同时存在于归档目录和旧目录,拒绝自动选择或覆盖") + } + } else if !os.IsNotExist(err) { + return err + } if _, err := os.Lstat(filepath.Join(root, "ssl", name)); err == nil { modern = true } else if !os.IsNotExist(err) { @@ -32,7 +41,7 @@ func DeploymentLayout(root string) bool { if _, err := os.Lstat(filepath.Join(root, "data", "credential.key")); err == nil { return false } - for _, name := range []string{"data/smail-state.db", "ssl/credential.key"} { + for _, name := range []string{"data/smail-state.db", "ssl/credential.key", "ssl/credentials/credential.key"} { info, err := os.Lstat(filepath.Join(root, filepath.FromSlash(name))) if err == nil && info.Mode().IsRegular() { return true @@ -42,6 +51,12 @@ func DeploymentLayout(root string) bool { } func SecureDirectory(root, name string) (string, error) { + if !filepath.IsLocal(name) || strings.ContainsAny(name, `/\`) || name == "." { + return "", errors.New("受保护目录名称无效") + } + if err := safeDirectory(root); err != nil { + return "", err + } directory := filepath.Join(root, name) if err := os.Mkdir(directory, 0700); err != nil && !os.IsExist(err) { return "", err @@ -53,10 +68,7 @@ func SecureDirectory(root, name string) (string, error) { } func KeyDirectory(root string) string { - if DeploymentLayout(root) { - return filepath.Join(root, "ssl") - } - return filepath.Join(root, "data") + return filepath.Dir(KeyPath(root, "credential.key")) } func SSLReference(root, value string) string { diff --git a/backend/utils/runtimepath/layout.go b/backend/utils/runtimepath/layout.go index 5409496..fb4123e 100644 --- a/backend/utils/runtimepath/layout.go +++ b/backend/utils/runtimepath/layout.go @@ -184,7 +184,7 @@ func CheckReady(root string) error { if config.SetupState != "ready" { return nil } - required := []string{"credential.key", "external-email-encryption.key", "external-email-fingerprint.key"} + required := BusinessKeyNames() if config.Database.Type == "sqlite" { if !filepath.IsLocal(config.Database.LocalRef) { return errors.New("SQLite 本地引用无效") @@ -194,7 +194,12 @@ func CheckReady(root string) error { for _, name := range required { base := filepath.Join(root, "data") if strings.HasSuffix(name, ".key") { - base = KeyDirectory(root) + base = filepath.Dir(KeyPath(root, name)) + if DeploymentLayout(root) { + if err := safeDirectory(filepath.Join(root, "ssl")); err != nil { + return err + } + } } if err := safeDirectory(base); err != nil { return err diff --git a/deploy/runtime-config.mjs b/deploy/runtime-config.mjs index ad1101a..677902a 100644 --- a/deploy/runtime-config.mjs +++ b/deploy/runtime-config.mjs @@ -19,8 +19,21 @@ export function assertDeploymentLayout(root = runtimeRoot) { resolveRuntimeFile(root, `config/${name}.json`) } if (config.setupState === 'ready') { - for (const name of ['credential.key', 'external-email-encryption.key', 'external-email-fingerprint.key']) { - resolveRuntimeFile(root, `ssl/${name}`) + for (const [folder, name] of [ + ['credentials', 'credential.key'], + ['external-email-encryption', 'external-email-encryption.key'], + ['external-email-fingerprint', 'external-email-fingerprint.key'] + ]) { + const grouped = `ssl/${folder}/${name}` + const legacy = `ssl/${name}` + // 启动前兼容有效旧位置,迁移由新后端完成,检查器不生成或移动密钥。 + const locations = [grouped, legacy, `data/${name}`, `config/${name}`] + .filter(reference => lstatSync(path.join(root, reference), { throwIfNoEntry: false })) + if (locations.length !== 1 || ![grouped, legacy].includes(locations[0])) { + throw new Error('业务密钥缺失或新旧位置冲突,拒绝自动选择') + } + const target = resolveRuntimeFile(root, locations[0]) + if (lstatSync(target).size !== 32) throw new Error('业务密钥长度无效') } if (config.database?.type === 'sqlite') resolveRuntimeFile(root, `data/${config.database.localRef}`) } @@ -38,6 +51,15 @@ export function resolveRuntimeFile(root, reference) { } const resolvedRoot = realpathSync(root) const absolute = path.resolve(root, reference) + const lexical = path.relative(path.resolve(root), absolute) + if (!lexical || lexical === '..' || lexical.startsWith(`..${path.sep}`) || path.isAbsolute(lexical)) { + throw new Error('运行文件不能越过部署目录') + } + let current = path.resolve(root) + for (const component of lexical.split(path.sep)) { + current = path.join(current, component) + if (lstatSync(current).isSymbolicLink()) throw new Error('运行文件路径不能包含链接') + } const info = lstatSync(absolute) if (!info.isFile() || info.isSymbolicLink()) throw new Error('运行文件必须是普通文件') const target = realpathSync(absolute) diff --git a/docs/部署后自动归档与证书保留.md b/docs/部署后自动归档与证书保留.md new file mode 100644 index 0000000..b7b27ab --- /dev/null +++ b/docs/部署后自动归档与证书保留.md @@ -0,0 +1,95 @@ +# 部署后自动归档与证书保留 + +## 已确认要求 + +- 正式程序在任意部署目录启动后自动创建目录,不依赖手动整理或开发脚本。 +- 已采用 `config/ssl/data/log` 布局的旧部署,启动时自动归档三把业务密钥,保留原字节。 +- `ssl/managed` 自动保留两套受管证书材料,后续导入、续期、应用及重启继续执行。 +- 域名证书原文件继续保存在 `ssl/` 根目录,不与业务加密密钥混放。 +- 模拟部署使用 `yunx-test/`,修改完成后必须完整构建并核验最新二进制。 + +## 程序创建的目录 + +```text +部署目录/ +├── config/ # 初始化完成后的五个生效 JSON +├── ssl/ +│ ├── credentials/ +│ │ └── credential.key +│ ├── external-email-encryption/ +│ │ └── external-email-encryption.key +│ ├── external-email-fingerprint/ +│ │ └── external-email-fingerprint.key +│ ├── dkim/ +│ ├── managed/ # default 与 upload 合计保留两套 +│ ├── 域名.crt +│ └── 域名.key +├── data/ +├── log/ +├── smail +└── smail-admin +``` + +目录由主服务启动入口 `InitializeDeployment` 创建;密钥由对应业务首次需要时创建,不能为凑齐文件生成无用密钥。Windows 二进制带 `.exe` 后缀。初始化未完成时不会伪造五个完成态配置文件。 + +三个目录分别保存配置凭据加密密钥、外部邮箱加密密钥、外部邮箱检索指纹密钥。ACME 账户密钥的进一步归档不在本次三个业务目录改动中,原文件继续保留。 + +## 启动迁移 + +启动先检查再移动:已初始化部署缺少密钥或数据库、新旧密钥重复、密钥不是 32 字节、存在链接或无效目标目录时拒绝继续,不覆盖、不重新生成替代密钥。Linux 还检查密钥文件权限。 + +有效的 `ssl/credential.key` 等旧文件使用重命名归档到业务目录,内容不变,因此旧配置密文、外部邮箱密文及指纹仍有效。每次移动后同步目录;迁移中断后允许下次启动继续,重复启动不会再次生成密钥。 + +更早的 `config/` 或 `data/` 混放部署仍须执行原有完整迁移流程,不能只搬三把密钥而遗漏数据库、证书和历史引用。 + +## 两份保留规则 + +- 一份是完整的证书和私钥组合,不是两个单独文件。 +- 当前生效证书优先保留;提交新版本时保留上一份材料;其余按版本目录时间排序补足两份。 +- 待应用候选、正在导入或续期的材料、磁盘已提交引用受到保护;操作进行时可能暂时超过两份,应用或取消后再次清理。 +- 启动后、定期证书维护、配置应用、回滚、取消候选及重新加载时自动执行。 +- 仅删除程序识别的历史版本。未知文件、链接、无法安全检查的目录保留并告警;事务恢复未完成时暂停清理。 +- 私钥文件锁保护跨进程暂存,配置状态锁协调提交与清理。停止或替换管理器时释放暂存锁。 +- 上一份证书过期不会阻止正常清理,但运行加载和回滚仍执行完整证书有效期、强度、配对检查。 +- 已清理证书的历史配置不能回滚,预览和执行均返回明确中文错误,必须重新导入有效证书。 + +## 域名文件与运行引用的边界 + +`ssl/域名.crt`、`ssl/域名.key` 是用户放置的证书原文件,自动归档和清理不删除它们。 + +**本次没有将服务运行引用改成根目录文件。** 自定义证书仍经校验后导入 `ssl/managed/.../version-...`,配置事务切换成对的受管引用,避免外部文件变化或两文件替换期间配对不一致。若要求运行引用也固定为 `ssl/域名.crt` 与 `ssl/域名.key`,还需实现完整的成对发布、失败恢复和备份引用迁移,不能只复制文件或直接改 JSON 冒充完成。 + +## 验证与边界 + +- 自动化回归覆盖新建目录、旧密钥字节不变、中断续迁、重复启动、冲突拒绝、跨模式总数两份、暂存保护、未知文件保护、过期前版本、旧 `current` 清理及历史回滚拒绝。 +- `test/deployment/verify_ssl_startup.py` 在原前后端停服且最新包已部署后运行,将原 `yunx-test` 暂存至 `test-log`,用同一二进制在空 `yunx-test` 正式启动两次,再恢复原部署。所有测试产物保留,不删除业务库。 +- 空目录正式启动只验收自动目录创建和初始化页面可达,不等于完成整个首次初始化、管理员恢复权限或插件生产隔离验收。 +- Linux 编译通过不等于 Linux 生产运行验收。正式管理员恢复通道的保护不降低。 + +## 本轮实测记录(2026-09-08) + +以下记录来自本轮最新源码构建,不以手工创建当前目录代替启动验证。 + +| 验证项目 | 实测结果 | 本地记录 | +| --- | --- | --- | +| 空目录正式启动两次 | 仅放两个程序,使用 `smail.exe -p 15201`,两次均自动创建部署目录及五个 SSL 子目录,初始化页面返回 200 | `test-log/ssl-startup-20260908-202756/result.json` | +| 旧部署自动升级 | 三把业务密钥归档前后字节不变,旧位置不再保留重复文件 | `test-log/ssl-upgrade-result-20260908.json` | +| 旧证书自动清理 | 由新程序启动完成,受管材料从 11 套收敛为 2 套,当前证书和私钥仍存在 | 同上 | +| 原文件保护 | 五个生效 JSON、根目录证书、ACME 密钥及其他原 SSL 文件字节不变;已有运行目录完整恢复 | 同上及空目录启动记录 | +| 邮件 TLS | 465、993、995 均完成 TLS 1.3 握手,实际证书指纹与当前配置指向的证书一致 | `test-log/ssl-upgrade-tls-20260908.log` | +| 全量自动化回归 | 前端 140/140、部署 4/4、Go 全量通过 | `test-log/run-2026-09-08T12-25-13-445Z-12312/` | +| 专项并发检查 | 配置、运行路径、配置凭据、外部邮箱及 SSL 设置包的 `-race` 测试通过 | `test-log/run-2026-09-08T12-26-15-343Z-27176/` | +| 最新部署包 | 前端、三个插件、Windows 内嵌资源主程序及管理员程序完成构建,源码指纹和部署哈希通过 `node deploy/build-state.mjs verify --deployed` 核验 | `out/build-state.json` | + +当前页面优化环境保留为前端源码热更新、后端最新编译程序,运行配置统一读取 `yunx-test/config/`。前端 HTTPS 及后端 `/api/ping` 均返回 200;后端使用已明确标记的开发恢复通道豁免参数,与上表不含该参数的空目录正式启动测试分开记录。 + +本轮没有进行浏览器页面交互验收,也没有宣称完整生产初始化或 Linux 运行验收通过。前端已有富文本编辑器分包体积提醒不在本次修改范围内。 + +## 任务状态 + +- [✓] 正式主程序自动创建业务目录,不依赖部署脚本创建。 +- [✓] 已采用 SSL 布局的旧部署自动迁移三把业务密钥,重复启动不更换密钥。 +- [✓] 受管证书正常稳定状态保留两套,保护生效、暂存和恢复所需材料。 +- [✓] 最新包部署、空目录启动、旧部署升级及邮件 TLS 验证完成。 +- [!] 根目录域名文件直接作为运行引用尚未改造;本轮只保证原文件保留,并继续使用已校验的受管副本。 +- [!] Linux 正式运行、完整首次初始化及正式管理员恢复流程尚未完成本轮实机验收。 diff --git a/test/backend/data_paths_test.go b/test/backend/data_paths_test.go index 830ba60..c681a6e 100644 --- a/test/backend/data_paths_test.go +++ b/test/backend/data_paths_test.go @@ -55,6 +55,8 @@ func TestDataMigrationKeepsSecretsCertificatesAndBackupRestorable(t *testing.T) t.Fatal(err) } // 构造旧版磁盘布局;文件内容及密文均来自真实实现,而不是手写假密钥。 + // 模拟停服时释放暂存租约,真实进程退出后操作系统也会释放文件锁。 + releaseManagedSSL(root, SSLConfig{CertificateRef: reference.CertificateRef}) entries, _ := os.ReadDir(filepath.Join(root, "data")) for _, entry := range entries { if err := os.Rename(filepath.Join(root, "data", entry.Name()), filepath.Join(root, "config", entry.Name())); err != nil { diff --git a/test/backend/deployment_state_test.go b/test/backend/deployment_state_test.go index 9e936dc..c2c782f 100644 --- a/test/backend/deployment_state_test.go +++ b/test/backend/deployment_state_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "git.pcux.cn/xuans/Smail/services/externalemail" "git.pcux.cn/xuans/Smail/utils/credential" "git.pcux.cn/xuans/Smail/utils/runtimepath" ) @@ -171,3 +172,84 @@ func TestDeploymentManagedReferenceUsesArgumentRoot(t *testing.T) { t.Fatalf("引用错误:%s", ref.CertificateRef) } } + +func TestDeploymentAutomaticallyCreatesBusinessDirectories(t *testing.T) { + root := t.TempDir() + if err := InitializeDeployment(root); err != nil { + t.Fatal(err) + } + for _, name := range []string{"config", "ssl", "data", "log"} { + if info, err := os.Lstat(filepath.Join(root, name)); err != nil || !info.IsDir() { + t.Fatalf("正式启动没有创建部署目录:%s", name) + } + } + for _, name := range []string{"credentials", "external-email-encryption", "external-email-fingerprint", "dkim", "managed"} { + info, err := os.Lstat(filepath.Join(root, "ssl", name)) + if err != nil || !info.IsDir() { + t.Fatalf("程序启动没有创建业务目录:%s", name) + } + } + if _, err := credential.Encrypt(root, "fixture"); err != nil { + t.Fatal(err) + } + if _, err := externalemail.NewVault(root); err != nil { + t.Fatal(err) + } + for _, name := range runtimepath.BusinessKeyNames() { + if filepath.Dir(runtimepath.KeyPath(root, name)) == filepath.Join(root, "ssl") { + t.Fatal("新密钥仍在 ssl 根目录生成") + } + if info, err := os.Stat(runtimepath.KeyPath(root, name)); err != nil || info.Size() != 32 { + t.Fatal("业务密钥未正确生成") + } + } +} + +func TestDeploymentAutomaticallyMigratesOriginalKeyBytes(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "ssl"), 0700); err != nil { + t.Fatal(err) + } + for index, name := range runtimepath.BusinessKeyNames() { + if err := os.WriteFile(filepath.Join(root, "ssl", name), bytes.Repeat([]byte{byte(index + 1)}, 32), 0600); err != nil { + t.Fatal(err) + } + } + secret, err := credential.Encrypt(root, "preserved-secret") + if err != nil { + t.Fatal(err) + } + oldVault, err := externalemail.NewVault(root) + if err != nil { + t.Fatal(err) + } + email, _ := oldVault.Encrypt("user@example.com") + fingerprint, _ := oldVault.Fingerprint("user@example.com") + for attempt := 0; attempt < 3; attempt++ { + if err := InitializeDeployment(root); err != nil { + t.Fatal(err) + } + for index, name := range runtimepath.BusinessKeyNames() { + actual, err := os.ReadFile(runtimepath.KeyPath(root, name)) + if err != nil || !bytes.Equal(actual, bytes.Repeat([]byte{byte(index + 1)}, 32)) { + t.Fatal("迁移或重复启动改变了原密钥") + } + if _, err := os.Lstat(filepath.Join(root, "ssl", name)); !os.IsNotExist(err) { + t.Fatal("旧位置未迁移") + } + } + } + if value, err := credential.Decrypt(root, secret); err != nil || value != "preserved-secret" { + t.Fatal("旧凭据密文无法解密") + } + vault, err := externalemail.NewVault(root) + if err != nil { + t.Fatal(err) + } + if value, err := vault.Decrypt(email); err != nil || value != "user@example.com" { + t.Fatal("外部邮箱旧密文无法解密") + } + if value, err := vault.Fingerprint("user@example.com"); err != nil || value != fingerprint { + t.Fatal("邮箱索引指纹发生变化") + } +} diff --git a/test/backend/runtime_layout_test.go b/test/backend/runtime_layout_test.go index f56730f..ad876a7 100644 --- a/test/backend/runtime_layout_test.go +++ b/test/backend/runtime_layout_test.go @@ -154,3 +154,49 @@ func TestLegacyReferenceOnlyChangesKnownRelativeDirectories(t *testing.T) { } } } + +func TestGroupedKeysRejectConflictMissingAndUnsafeTarget(t *testing.T) { + for _, failure := range []string{"conflict", "missing", "unsafe", "invalid"} { + t.Run(failure, func(t *testing.T) { + root := t.TempDir() + put(t, root, "data/smail-state.db", []byte("marker")) + put(t, root, "config/config.json", []byte(`{"setupState":"ready","database":{"type":"mysql"}}`)) + for _, name := range BusinessKeyNames() { + if failure != "missing" || name != "external-email-fingerprint.key" { + put(t, root, "ssl/"+name, bytes.Repeat([]byte{1}, 32)) + } + } + switch failure { + case "conflict": + put(t, root, "ssl/credentials/credential.key", bytes.Repeat([]byte{2}, 32)) + case "unsafe": + put(t, root, "ssl/external-email-fingerprint", []byte("not-directory")) + case "invalid": + put(t, root, "ssl/external-email-encryption.key", []byte("invalid")) + } + if err := PrepareDeploymentKeys(root); err == nil { + t.Fatal("异常部署不应继续自动迁移") + } + if data, err := os.ReadFile(filepath.Join(root, "ssl", "credential.key")); err != nil || !bytes.Equal(data, bytes.Repeat([]byte{1}, 32)) { + t.Fatal("预检失败修改了原密钥") + } + }) + } +} + +func TestGroupedKeysResumePartiallyMovedDeployment(t *testing.T) { + root := t.TempDir() + put(t, root, "data/smail-state.db", []byte("marker")) + put(t, root, "config/config.json", []byte(`{"setupState":"ready","database":{"type":"mysql"}}`)) + put(t, root, "ssl/credentials/credential.key", bytes.Repeat([]byte{1}, 32)) + put(t, root, "ssl/external-email-encryption.key", bytes.Repeat([]byte{2}, 32)) + put(t, root, "ssl/external-email-fingerprint.key", bytes.Repeat([]byte{3}, 32)) + if err := PrepareDeploymentKeys(root); err != nil { + t.Fatal(err) + } + for index, name := range BusinessKeyNames() { + if data, err := os.ReadFile(groupedKeyPath(root, name)); err != nil || !bytes.Equal(data, bytes.Repeat([]byte{byte(index + 1)}, 32)) { + t.Fatal("中断恢复改变了原始密钥") + } + } +} diff --git a/test/backend/ssl_retention_test.go b/test/backend/ssl_retention_test.go new file mode 100644 index 0000000..c4a504e --- /dev/null +++ b/test/backend/ssl_retention_test.go @@ -0,0 +1,250 @@ +package config + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func sslConfigForReference(ref ManagedSSLReference) SSLConfig { + return SSLConfig{Mode: ref.Mode, CertificateRef: ref.CertificateRef, PrivateKeyRef: ref.PrivateKeyRef} +} + +func countManagedVersions(t *testing.T, root string) int { + t.Helper() + count := 0 + for _, mode := range []string{ManagedSSLModeDefault, ManagedSSLModeUpload} { + path, err := managedSSLRootPathAtRoot(root, mode) + if err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(path) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + for _, entry := range entries { + if validSSLVersion(entry.Name()) { + count++ + } + } + } + return count +} + +func TestSSLRetentionHandlesExpiredPreviousAndLegacyCurrent(t *testing.T) { + root := t.TempDir() + if err := InitializeDeployment(root); err != nil { + t.Fatal(err) + } + key, cert := testManagedCertificate(t, "example.com", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048) + legacy, err := importManagedSSLAtRoot(root, ManagedSSLModeDefault, key, cert, nil, nil) + if err != nil { + t.Fatal(err) + } + var refs []ManagedSSLReference + for index := 0; index < 4; index++ { + ref, err := stageManagedSSLAtRoot(root, ManagedSSLModeUpload, key, cert, nil, nil) + if err != nil { + t.Fatal(err) + } + releaseManagedSSL(root, sslConfigForReference(ref)) + refs = append(refs, ref) + } + expiredKey, expiredCert := testManagedCertificate(t, "example.com", time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), 2048) + if err := os.WriteFile(refs[2].PrivateKeyPath, expiredKey, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(refs[2].CertificatePath, expiredCert, 0600); err != nil { + t.Fatal(err) + } + maintainManagedSSL(root, sslConfigForReference(refs[3]), sslConfigForReference(refs[2])) + if countManagedVersions(t, root) != 2 { + t.Fatal("上一份证书过期阻止了清理") + } + if _, err := os.Stat(legacy.CertificatePath); !os.IsNotExist(err) { + t.Fatal("未被引用的旧 current 未清理") + } + if _, err := os.Stat(refs[2].CertificatePath); err != nil { + t.Fatal("清理删除了受保护的上一份证书") + } + old := validRuntimeConfig() + old.SSL.TLS = sslConfigForReference(legacy) + if err := validateRollbackSSL(root, old); !errors.Is(err, ErrSSLHistoryUnavailable) { + t.Fatal("历史 current 丢失后仍允许回滚") + } +} + +func TestSSLRetentionProtectsIndependentLease(t *testing.T) { + root := t.TempDir() + if err := InitializeDeployment(root); err != nil { + t.Fatal(err) + } + key, cert := testManagedCertificate(t, "example.com", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048) + var refs []ManagedSSLReference + for index := 0; index < 4; index++ { + ref, err := stageManagedSSLAtRoot(root, ManagedSSLModeUpload, key, cert, nil, nil) + if err != nil { + t.Fatal(err) + } + releaseManagedSSL(root, sslConfigForReference(ref)) + refs = append(refs, ref) + } + lease, err := openSSLLease(refs[0].PrivateKeyPath) + if err != nil { + t.Fatal(err) + } + defer lease.Close() + unlock, err := lockStateFile(lease) + if err != nil { + t.Fatal(err) + } + defer unlock() + maintainManagedSSL(root, sslConfigForReference(refs[3])) + if _, err := os.Stat(refs[0].CertificatePath); err != nil { + t.Fatal("清理忽略了其他句柄持有的暂存租约") + } + if countManagedVersions(t, root) != 2 { + t.Fatal("暂存和生效证书以外的历史未清理") + } +} + +func TestSSLRetentionKeepsTwoAcrossModesAndProtectsStaged(t *testing.T) { + root := t.TempDir() + if err := InitializeDeployment(root); err != nil { + t.Fatal(err) + } + key, cert := testManagedCertificate(t, "example.com", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048) + var refs []ManagedSSLReference + for index := 0; index < 4; index++ { + mode := ManagedSSLModeDefault + if index%2 == 1 { + mode = ManagedSSLModeUpload + } + ref, err := stageManagedSSLAtRoot(root, mode, key, cert, nil, nil) + if err != nil { + t.Fatal(err) + } + refs = append(refs, ref) + if index > 0 { + maintainManagedSSL(root, sslConfigForReference(refs[index-1])) + if _, err := os.Stat(ref.PrivateKeyPath); err != nil { + t.Fatal("清理误删尚未提交的续期版本") + } + maintainManagedSSL(root, sslConfigForReference(ref), sslConfigForReference(refs[index-1])) + } else { + maintainManagedSSL(root, sslConfigForReference(ref)) + } + want := index + 1 + if want > 2 { + want = 2 + } + if actual := countManagedVersions(t, root); actual != want { + t.Fatalf("跨模式保留总量错误:%d,期望 %d", actual, want) + } + } + old := validRuntimeConfig() + old.SSL.TLS = sslConfigForReference(refs[0]) + if err := validateRollbackSSL(root, old); !errors.Is(err, ErrSSLHistoryUnavailable) { + t.Fatalf("旧证书已清理却允许回滚:%v", err) + } + // 即使当前证书的目录时间最旧,也必须保留当前证书。 + if err := os.Chtimes(filepath.Dir(refs[2].CertificatePath), time.Unix(1, 0), time.Unix(1, 0)); err != nil { + t.Fatal(err) + } + maintainManagedSSL(root, sslConfigForReference(refs[2])) + if _, err := resolveManagedSSLReferencesAtRoot(root, sslConfigForReference(refs[2])); err != nil { + t.Fatal("清理删除了旧时间的生效证书") + } +} + +func TestSSLRetentionDoesNotPartiallyDeleteUnknownFiles(t *testing.T) { + directory := filepath.Join(t.TempDir(), "version-00000000000000000000000000000000") + if err := os.Mkdir(directory, 0700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"private.key", "public.crt", "zzz-user-note.txt"} { + if err := os.WriteFile(filepath.Join(directory, name), []byte("preserved"), 0600); err != nil { + t.Fatal(err) + } + } + if err := removeManagedSSLDirectory(directory); !errors.Is(err, ErrUnsafeSSLFile) { + t.Fatal("未知文件没有阻止删除") + } + for _, name := range []string{"private.key", "public.crt", "zzz-user-note.txt"} { + if _, err := os.Stat(filepath.Join(directory, name)); err != nil { + t.Fatal("预检期间已经删除了部分文件") + } + } +} + +func TestSSLRetentionRunsOnApplyCancelAndReload(t *testing.T) { + root, manager, request, _ := customSSLFixture(t) + var first RuntimeConfig + for index := 0; index < 4; index++ { + request.ExpectedGeneration = manager.Snapshot().Generation + updated, err := manager.Update(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionSSL, request.Actor); err != nil { + t.Fatal(err) + } + if index == 0 { + first = manager.Snapshot() + if _, err := manager.transactions.CreateBackup(first); err != nil { + t.Fatal(err) + } + } + if index > 0 && countManagedVersions(t, root) != 2 { + t.Fatal("配置应用完成后未自动保留两份") + } + } + if _, err := manager.PreviewRollback(context.Background(), first.Generation, request.Actor); !errors.Is(err, ErrSSLHistoryUnavailable) { + t.Fatalf("预览允许回滚已经清理的证书:%v", err) + } + if _, _, err := manager.rollbackWithState(context.Background(), first.Generation, request.Actor, StepUpConfirmation{Confirmed: true, Method: "ticket"}); !errors.Is(err, ErrSSLHistoryUnavailable) { + t.Fatalf("实际回滚没有拒绝已清理证书:%v", err) + } + request.ExpectedGeneration = manager.Snapshot().Generation + updated, err := manager.Update(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if err := manager.ManagePending(context.Background(), updated.Generation, updated.CandidateID, request.Actor, StepUpConfirmation{}, true); err != nil { + t.Fatal(err) + } + if countManagedVersions(t, root) != 2 { + t.Fatal("取消候选未收敛到两份") + } + if err := manager.Load(context.Background()); err != nil { + t.Fatal(err) + } + if countManagedVersions(t, root) != 2 { + t.Fatal("重新加载后保留数量错误") + } + // 确认五分区仍可完整读取,证书删除不能导致摘要被直接修改。 + data, _ := json.Marshal(manager.Snapshot().SSL) + if len(data) == 0 { + t.Fatal("配置快照不可读") + } + request.ExpectedGeneration = manager.Snapshot().Generation + updated, err = manager.Update(context.Background(), request) + if err != nil { + t.Fatal(err) + } + manager.releasePendingSSL() + path, err := resolveManagedSSLPathsAtRoot(root, updated.Candidate.SSL.TLS) + if err != nil { + t.Fatal(err) + } + managedSSLStorage.Lock() + stillLeased := managedSSLStorage.staged[filepath.Dir(path.CertificatePath)] != nil + managedSSLStorage.Unlock() + if stillLeased { + t.Fatal("管理器停止后仍持有暂存租约") + } +} diff --git a/test/deployment/verify_ssl_startup.py b/test/deployment/verify_ssl_startup.py new file mode 100644 index 0000000..b5f8ca5 --- /dev/null +++ b/test/deployment/verify_ssl_startup.py @@ -0,0 +1,120 @@ +"""使用最新二进制验证空目录正式启动,保留并恢复现有模拟部署。""" + +import hashlib +import http.client +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import time + + +ROOT = Path(__file__).resolve().parents[2] +RUNTIME = ROOT / "yunx-test" +BUSINESS = { + "credential.key": "credentials", + "external-email-encryption.key": "external-email-encryption", + "external-email-fingerprint.key": "external-email-fingerprint", +} + + +def checked(path): + absolute = path.resolve() + if not absolute.is_relative_to(ROOT) or absolute == ROOT or path.is_symlink(): + raise RuntimeError("测试路径越过项目边界或为链接") + return absolute + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + for service_port in (80, 443, 5201): + with socket.socket() as probe: + probe.settimeout(0.5) + if probe.connect_ex(("127.0.0.1", service_port)) == 0: + raise RuntimeError("请先停止模拟部署的前后端,不能移动运行中的部署") + receipt = json.loads((ROOT / "out" / "build-state.json").read_text(encoding="utf-8")) + for item in receipt["files"]: + if item["target"] and digest(RUNTIME / item["target"]) != item["sha256"]: + raise RuntimeError("部署二进制与最新构建清单不一致") + port = 15201 + with socket.socket() as probe: + probe.bind(("127.0.0.1", port)) + record = checked(ROOT / "test-log" / time.strftime("ssl-startup-%Y%m%d-%H%M%S")) + record.mkdir() + saved = checked(record / "existing-runtime") + fixture = checked(record / "fresh-runtime") + checked(RUNTIME) + # 必须由调用方先停止本项目两个服务;部署入口自身也会拒绝覆盖运行程序。 + RUNTIME.rename(saved) + result = {"productionArguments": ["-p", str(port)], "starts": []} + process = None + try: + RUNTIME.mkdir() + for name in ("smail.exe", "smail-admin.exe"): + shutil.copy2(saved / name, RUNTIME / name) + env = dict(os.environ) + env["SMAIL_ROOT"] = str(RUNTIME) + env["SMAIL_SETUP_HOST"] = "127.0.0.1" + for name in ("SMAIL_HTTP_PORT_OVERRIDE", "SMAIL_HTTP_TLS_MODE_OVERRIDE", "SMAIL_SETUP_TOKEN", "SMAIL_SETUP_ALLOWED_CIDRS"): + env.pop(name, None) + first = None + for attempt in range(2): + with (record / f"startup-{attempt + 1}.log").open("wb") as log: + process = subprocess.Popen( + [str(RUNTIME / "smail.exe"), "-p", str(port)], cwd=RUNTIME, + env=env, stdout=log, stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + ready = False + for _ in range(100): + if process.poll() is not None: + raise RuntimeError("正式启动提前退出,请检查测试日志") + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=1) + try: + connection.request("GET", "/") + response = connection.getresponse() + ready = response.status == 200 + response.read() + except OSError: + pass + finally: + connection.close() + if ready: + break + time.sleep(0.1) + if not ready: + raise RuntimeError("正式启动未进入初始化页面") + for folder in ("config", "ssl", "data", "log"): + if not (RUNTIME / folder).is_dir(): + raise RuntimeError("正式启动没有创建部署目录") + directories = [str(p.relative_to(RUNTIME)) for p in sorted((RUNTIME / "ssl").iterdir()) if p.is_dir()] + for folder in (*BUSINESS.values(), "dkim", "managed"): + if not (RUNTIME / "ssl" / folder).is_dir(): + raise RuntimeError("主程序没有自动创建业务目录") + if first is not None and directories != first: + raise RuntimeError("重复启动的业务目录结构发生变化") + first = directories + result["starts"].append({"httpStatus": 200, "sslDirectories": directories}) + process.terminate() + process.wait(timeout=15) + process = None + result["binaryMatchesLatest"] = digest(RUNTIME / "smail.exe") == digest(saved / "smail.exe") + finally: + if process is not None and process.poll() is None: + process.kill() + process.wait(timeout=15) + if RUNTIME.exists(): + checked(RUNTIME).rename(fixture) + checked(saved).rename(RUNTIME) + result["existingRuntimeRestored"] = True + (record / "result.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print("空目录正式启动和重复启动通过,现有部署已恢复:", record.relative_to(ROOT)) + + +if __name__ == "__main__": + main() diff --git a/test/frontend/runtime-deployment.test.mjs b/test/frontend/runtime-deployment.test.mjs index 99b94df..7f90385 100644 --- a/test/frontend/runtime-deployment.test.mjs +++ b/test/frontend/runtime-deployment.test.mjs @@ -1,9 +1,9 @@ import assert from 'node:assert/strict' -import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import test from 'node:test' -import { readDevelopmentRuntime, resolveRuntimeFile, runtimeRoot, sourceRoot } from '../../deploy/runtime-config.mjs' +import { assertDeploymentLayout, readDevelopmentRuntime, resolveRuntimeFile, runtimeRoot, sourceRoot } from '../../deploy/runtime-config.mjs' import { fileDigest, sourceFingerprint } from '../../deploy/build-state.mjs' test('开发配置只从模拟部署目录读取且不返回凭据字段', async () => { @@ -45,6 +45,15 @@ test('源码指纹检测编辑且忽略编译嵌入目录', async () => { await assert.rejects(fileDigest(path.join(root, 'empty.exe'))) }) +test('运行文件拒绝通过父目录链接访问另一个业务目录', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'smail-runtime-link-')) + await mkdir(path.join(root, 'ssl')) + await mkdir(path.join(root, 'data')) + await writeFile(path.join(root, 'data/private.key'), 'fixture') + await symlink(path.join(root, 'data'), path.join(root, 'ssl/linked'), process.platform === 'win32' ? 'junction' : 'dir') + assert.throws(() => resolveRuntimeFile(root, 'ssl/linked/private.key'), /链接/) +}) + test('部署和启动必须完整构建、验证产物且不覆盖运行配置', async () => { const read = name => readFile(path.join(sourceRoot, name), 'utf8') const deploy = await read('deploy/deploy-runtime.ps1') @@ -59,3 +68,28 @@ test('部署和启动必须完整构建、验证产物且不覆盖运行配置', assert.match(start, /build-state\.mjs'\) verify --deployed/) assert.match(await read('.gitignore'), /^\/yunx-test\/$/m) }) + +test('部署启动检查兼容自动归档前后的密钥且拒绝双份冲突', async () => { + for (const grouped of [false, true]) { + const root = await mkdtemp(path.join(os.tmpdir(), 'smail-key-layout-')) + for (const name of ['config', 'ssl', 'data', 'log']) await mkdir(path.join(root, name)) + await writeFile(path.join(root, 'config/config.json'), JSON.stringify({ setupState: 'ready', database: { type: 'mysql' } })) + for (const name of ['smtp', 'email', 'plugins']) await writeFile(path.join(root, `config/${name}.json`), '{}') + await writeFile(path.join(root, 'config/ssl.json'), JSON.stringify({ tls: { certificateRef: 'ssl/public.crt', privateKeyRef: 'ssl/private.key' } })) + await writeFile(path.join(root, 'ssl/public.crt'), 'certificate') + await writeFile(path.join(root, 'ssl/private.key'), 'key') + for (const [folder, name] of [ + ['credentials', 'credential.key'], + ['external-email-encryption', 'external-email-encryption.key'], + ['external-email-fingerprint', 'external-email-fingerprint.key'] + ]) { + if (grouped) await mkdir(path.join(root, 'ssl', folder)) + await writeFile(path.join(root, 'ssl', ...(grouped ? [folder] : []), name), Buffer.alloc(32, 1)) + } + assert.equal(assertDeploymentLayout(root).setupState, 'ready') + if (grouped) { + await writeFile(path.join(root, 'ssl/credential.key'), Buffer.alloc(32, 2)) + assert.throws(() => assertDeploymentLayout(root), /冲突/) + } + } +}) diff --git a/test/run.mjs b/test/run.mjs index aa8ea0c..d9f61ff 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -50,6 +50,7 @@ if ((suite === 'all' || suite === 'backend') && result === 0) { // 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。 const replacements = { 'backend/config/deployment_state_test.go': 'test/backend/deployment_state_test.go', + 'backend/config/ssl_retention_test.go': 'test/backend/ssl_retention_test.go', 'backend/utils/runtimepath/layout_test.go': 'test/backend/runtime_layout_test.go', 'backend/config/data_paths_test.go': 'test/backend/data_paths_test.go', 'backend/config/ssl_custom_regression_test.go': 'test/backend/ssl_custom_test.go',