This commit is contained in:
parent
48f4b24781
commit
a045a0cd17
@ -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 在停服迁移期间导入旧历史原始字节,原目录保持不动。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
9
backend/config/ssl_lease_unix.go
Normal file
9
backend/config/ssl_lease_unix.go
Normal 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)
|
||||
}
|
||||
24
backend/config/ssl_lease_windows.go
Normal file
24
backend/config/ssl_lease_windows.go
Normal 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
|
||||
}
|
||||
240
backend/config/ssl_retention.go
Normal file
240
backend/config/ssl_retention.go
Normal 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, §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("历史证书已清理或不可用,无法回滚此配置,请重新导入有效证书")
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -23,6 +23,7 @@ func Run() error {
|
||||
if err := config.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
config.MaintainManagedSSLHistory()
|
||||
|
||||
build := buildinfo.Current()
|
||||
if build.Version == "dev" {
|
||||
|
||||
@ -52,6 +52,7 @@ func sslCheck() {
|
||||
|
||||
// 每轮读取最新策略,切换反代模式后不再等待 HTTP 挑战或执行自动续期。
|
||||
func maintainSSL() {
|
||||
config.MaintainManagedSSLHistory()
|
||||
cfg := config.CurrentConfig()
|
||||
if cfg == nil || !cfg.IsSetupReady() {
|
||||
return
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
137
backend/utils/runtimepath/business_keys.go
Normal file
137
backend/utils/runtimepath/business_keys.go
Normal 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)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
95
docs/部署后自动归档与证书保留.md
Normal file
95
docs/部署后自动归档与证书保留.md
Normal 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 正式运行、完整首次初始化及正式管理员恢复流程尚未完成本轮实机验收。
|
||||
@ -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 {
|
||||
|
||||
@ -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("邮箱索引指纹发生变化")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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("中断恢复改变了原始密钥")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
250
test/backend/ssl_retention_test.go
Normal file
250
test/backend/ssl_retention_test.go
Normal 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("管理器停止后仍持有暂存租约")
|
||||
}
|
||||
}
|
||||
120
test/deployment/verify_ssl_startup.py
Normal file
120
test/deployment/verify_ssl_startup.py
Normal 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()
|
||||
@ -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), /冲突/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user