实现部署启动自动归档密钥与证书双版本保留
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-08 20:34:38 +08:00
parent 48f4b24781
commit a045a0cd17
27 changed files with 1209 additions and 24 deletions

View File

@ -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 在停服迁移期间导入旧历史原始字节,原目录保持不动。

View File

@ -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,

View File

@ -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
}

View File

@ -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。

View File

@ -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,

View File

@ -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):

View File

@ -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)
}

View File

@ -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
}

View File

@ -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, &section); 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("历史证书已清理或不可用,无法回滚此配置,请重新导入有效证书")

View File

@ -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)
}

View File

@ -23,6 +23,7 @@ func Run() error {
if err := config.Init(); err != nil {
return err
}
config.MaintainManagedSSLHistory()
build := buildinfo.Current()
if build.Version == "dev" {

View File

@ -52,6 +52,7 @@ func sslCheck() {
// 每轮读取最新策略,切换反代模式后不再等待 HTTP 挑战或执行自动续期。
func maintainSSL() {
config.MaintainManagedSSLHistory()
cfg := config.CurrentConfig()
if cfg == nil || !cfg.IsSetupReady() {
return

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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

View File

@ -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)

View File

@ -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 正式运行、完整首次初始化及正式管理员恢复流程尚未完成本轮实机验收。

View File

@ -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 {

View File

@ -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("邮箱索引指纹发生变化")
}
}

View File

@ -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("中断恢复改变了原始密钥")
}
}
}

View File

@ -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("管理器停止后仍持有暂存租约")
}
}

View File

@ -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()

View File

@ -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), /冲突/)
}
}
})

View File

@ -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',