From d7837de5611fd08219cd454b914b53df09ab4cba Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 8 Sep 2026 18:33:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=A8=A1=E6=8B=9F=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E8=BF=81=E7=A7=BB=E4=B8=8E=E6=9C=80=E6=96=B0=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E6=A0=A1=E9=AA=8C=E9=98=B6=E6=AE=B5=E6=88=90=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 33 +- .gitignore | 2 + Makefile | 20 +- backend/cmd/smail-config/main.go | 14 + backend/cmd/smail/bundle.go | 21 ++ backend/config/audit_store.go | 6 +- backend/config/backup_list.go | 13 + backend/config/config.go | 10 +- backend/config/deployment_state.go | 96 +++++ backend/config/partitioned_compatibility.go | 5 +- backend/config/ssl_custom.go | 13 +- backend/config/ssl_storage.go | 14 +- backend/config/state_lock_unix.go | 14 + backend/config/state_lock_windows.go | 15 + backend/config/state_store.go | 379 ++++++++++++++++++++ backend/config/transaction.go | 35 ++ backend/dto/parsemail/dkim.go | 4 +- backend/hooks/base.go | 7 +- backend/hooks/bundle.go | 52 +++ backend/internal/app/app.go | 3 + backend/res_init/init.go | 3 +- backend/services/auth/auth.go | 3 +- backend/services/externalemail/vault.go | 2 +- backend/services/setup/ssl/ssl.go | 7 +- backend/services/setup/ssl/storage.go | 5 +- backend/utils/credential/encryption.go | 8 +- backend/utils/runtimepath/deployment.go | 84 +++++ backend/utils/runtimepath/layout.go | 14 +- deploy/build-state.mjs | 91 +++++ deploy/build.ps1 | 47 ++- deploy/bundle-plugins.mjs | 23 ++ deploy/check-runtime.mjs | 9 + deploy/deploy-runtime.ps1 | 46 +++ deploy/package.mjs | 10 +- deploy/runtime-config.mjs | 68 ++++ deploy/start-dev.ps1 | 50 ++- deploy/start-production.ps1 | 35 ++ docs/模拟部署与最新构建.md | 45 +++ frontend/.env.development | 4 - frontend/vite.config.js | 13 +- test/backend/deployment_state_test.go | 173 +++++++++ test/deployment/migrate_runtime.py | 123 +++++++ test/frontend/output-paths.test.mjs | 16 +- test/frontend/runtime-deployment.test.mjs | 61 ++++ test/run.mjs | 1 + 45 files changed, 1588 insertions(+), 109 deletions(-) create mode 100644 backend/cmd/smail/bundle.go create mode 100644 backend/config/deployment_state.go create mode 100644 backend/config/state_lock_unix.go create mode 100644 backend/config/state_lock_windows.go create mode 100644 backend/config/state_store.go create mode 100644 backend/hooks/bundle.go create mode 100644 backend/utils/runtimepath/deployment.go create mode 100644 deploy/build-state.mjs create mode 100644 deploy/bundle-plugins.mjs create mode 100644 deploy/check-runtime.mjs create mode 100644 deploy/deploy-runtime.ps1 create mode 100644 deploy/runtime-config.mjs create mode 100644 deploy/start-production.ps1 create mode 100644 docs/模拟部署与最新构建.md create mode 100644 test/backend/deployment_state_test.go create mode 100644 test/deployment/migrate_runtime.py create mode 100644 test/frontend/runtime-deployment.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af786a8..1a87ebe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,36 +58,23 @@ jobs: run: cd frontend && yarn install --frozen-lockfile && yarn build - name: BE Build run: | - mkdir -p out/release/plugins - cd backend - BUILD_FLAGS="-s -w -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.Version=${{ env.VERSION }}' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.GoVersion=$(go version)' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.GitHash=$(git show -s --format=%H)' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)'" - go build -ldflags "$BUILD_FLAGS" -o ../out/release/${{ env.FILENAME }} ./cmd/smail - go build -ldflags "$BUILD_FLAGS" -o ../out/release/${{ env.ADMINFILENAME }} ./cmd/smail-admin - cd ../plugins/pmail_telegram_push + mkdir -p out/release/config out/release/ssl out/release/data out/release/log + cd plugins/pmail_telegram_push npm ci npm run type-check cd ../.. node deploy/build-plugins.mjs --target=${{ matrix.goos }}_${{ matrix.goarch }} - for name in spam_block pmail_telegram_push wechat_push; do - extension="" - if [ "${{ matrix.goos }}" = "windows" ]; then extension=".exe"; fi - cp "out/plugins/${{ matrix.goos }}_${{ matrix.goarch }}/$name$extension" "out/release/plugins/$name$extension" - done - cd out/release - cat > release.json < maxConfigFileSize || SectionDigest(files[name]) != digest { + return ErrConfigDigestMismatch + } + } + return nil +} + +func (s *TransactionStore) stateRecords(kind string) ([]stateRecord, error) { + db, err := s.openState() + if err != nil { + return nil, err + } + defer db.Close() + var oversized int + if err := db.QueryRow("SELECT count(*) FROM config_records WHERE kind=? AND length(payload)>?", kind, 16*maxConfigFileSize).Scan(&oversized); err != nil || oversized != 0 { + return nil, errors.Join(ErrTransactionInvalid, err) + } + rows, err := db.Query("SELECT id,payload,digest FROM config_records WHERE kind=? ORDER BY id", kind) + if err != nil { + return nil, err + } + defer rows.Close() + var result []stateRecord + for rows.Next() { + var id, digest string + var data []byte + if err := rows.Scan(&id, &data, &digest); err != nil { + return nil, err + } + if !transactionIDPattern.MatchString(id) || len(data) > 16*maxConfigFileSize || SectionDigest(data) != digest { + return nil, ErrConfigDigestMismatch + } + var record stateRecord + if err := ParseStrict(data, &record); err != nil { + return nil, err + } + switch kind { + case "transaction": + if record.Transaction == nil || record.Transaction.ID != id || record.Backup != nil { + return nil, ErrTransactionInvalid + } + if err := validateTransactionManifest(*record.Transaction); err != nil { + return nil, err + } + if err := validateStateFiles(record.Original, record.Transaction.OriginalFiles); err != nil { + return nil, err + } + if err := validateStateFiles(record.Candidate, record.Transaction.CandidateFiles); err != nil { + return nil, err + } + for _, item := range []struct { + files map[string][]byte + generation uint64 + }{ + {record.Original, record.Transaction.FromGeneration}, + {record.Candidate, record.Transaction.ToGeneration}, + } { + config, err := parseRuntimeFiles(item.files, s.validation) + if err != nil || config.Generation != item.generation { + return nil, errors.Join(ErrTransactionInvalid, err) + } + } + case "backup": + if record.Backup == nil || record.Backup.ID != id || record.Transaction != nil || record.Backup.CreatedAt.IsZero() { + return nil, ErrTransactionInvalid + } + if err := validateStateFiles(record.Original, record.Backup.Files); err != nil { + return nil, err + } + config, err := parseRuntimeFiles(record.Original, s.validation) + if err != nil { + return nil, err + } + if config.Generation != record.Backup.Generation { + return nil, ErrConfigGenerationMismatch + } + default: + return nil, ErrTransactionInvalid + } + result = append(result, record) + } + return result, rows.Err() +} + +func (s *TransactionStore) removeState(kind, id string) error { + db, err := s.openState() + if err != nil { + return err + } + defer db.Close() + _, err = db.Exec("DELETE FROM config_records WHERE kind=? AND id=?", kind, id) + return err +} + +func writeStateLive(directory string, files map[string][]byte, rootOnly bool) error { + for _, name := range transactionFileNames() { + if (name == "config.json") != rootOnly { + continue + } + target := filepath.Join(directory, name) + if info, err := os.Lstat(target); err == nil && !info.Mode().IsRegular() { + return ErrManagerStorage + } else if err != nil && !os.IsNotExist(err) { + return err + } + if err := atomicfile.Write(target, files[name], 0600); err != nil { + return err + } + } + return atomicfile.SyncDirectory(directory) +} + +func (s *TransactionStore) commitState(current, candidate RuntimeConfig) error { + unlock, err := s.lockState() + if err != nil { + return err + } + defer unlock() + if current.Generation == 0 || candidate.Generation != current.Generation+1 { + return ErrGenerationConflict + } + if err := ValidateRuntimeConfig(candidate, s.validation); err != nil { + return err + } + if records, err := s.stateRecords("transaction"); err != nil || len(records) != 0 { + return errors.Join(ErrConfigRecoveryRequired, err) + } + live, err := NewConfigLoader(s.root, ConfigLoaderOptions{Validation: s.validation}).Load() + if err != nil || live.Generation != current.Generation { + return errors.Join(ErrGenerationConflict, err) + } + original, err := marshalRuntimeFiles(current) + if err != nil { + return err + } + files, err := marshalRuntimeFiles(candidate) + if err != nil { + return err + } + directory, err := secureManagerConfigDirectory(s.root) + if err != nil { + return err + } + id, err := newTransactionID() + if err != nil { + return err + } + manifest := TransactionManifest{ + ID: id, FromGeneration: current.Generation, ToGeneration: candidate.Generation, + Sections: append([]Section(nil), requiredPartitionSections...), OriginalFiles: digestFiles(original), + CandidateFiles: digestFiles(files), Phase: TransactionPrepared, CreatedAt: time.Now().UTC(), + } + record := stateRecord{Transaction: &manifest, Original: original, Candidate: files} + persist := func(phase TransactionPhase) error { + manifest.Phase = phase + if err := s.saveState("transaction", id, record); err != nil { + return err + } + return s.beforePhase(phase) + } + if err := persist(TransactionPrepared); err != nil { + return err + } + if err := writeStateLive(directory, files, false); err != nil { + return err + } + if err := persist(TransactionSectionsCommitted); err != nil { + return err + } + if err := writeStateLive(directory, files, true); err != nil { + return err + } + if err := persist(TransactionRootCommitted); err != nil { + return err + } + loaded, err := NewConfigLoader(s.root, ConfigLoaderOptions{Validation: s.validation}).Load() + if err != nil || loaded.Generation != candidate.Generation { + return errors.Join(ErrConfigGenerationMismatch, err) + } + if err := persist(TransactionVerified); err != nil { + return err + } + return s.removeState("transaction", id) +} + +func (s *TransactionStore) recoverState() error { + unlock, err := s.lockState() + if err != nil { + return err + } + defer unlock() + if entries, err := os.ReadDir(filepath.Join(s.root, "data", "transaction")); err == nil && len(entries) != 0 { + return ErrConfigRecoveryRequired + } else if err != nil && !os.IsNotExist(err) { + return err + } + records, err := s.stateRecords("transaction") + if err != nil { + return err + } + if len(records) > 1 { + return ErrTransactionInvalid + } + for _, record := range records { + manifest := record.Transaction + loaded, err := NewConfigLoader(s.root, ConfigLoaderOptions{Validation: s.validation}).Load() + if err == nil && loaded.Generation > manifest.ToGeneration { + return ErrConfigGenerationMismatch + } + if err != nil || loaded.Generation < manifest.ToGeneration { + if _, err := parseRuntimeFiles(record.Original, s.validation); err != nil { + return err + } + directory, err := secureManagerConfigDirectory(s.root) + if err != nil { + return err + } + if err := writeStateLive(directory, record.Original, false); err != nil { + return err + } + if err := writeStateLive(directory, record.Original, true); err != nil { + return err + } + if _, err := NewConfigLoader(s.root, ConfigLoaderOptions{Validation: s.validation}).Load(); err != nil { + return err + } + } + if err := s.removeState("transaction", manifest.ID); err != nil { + return err + } + } + return nil +} + +func (s *TransactionStore) stateBackups() ([]stateRecord, error) { + items, err := s.stateRecords("backup") + sort.Slice(items, func(i, j int) bool { + if items[i].Backup.CreatedAt.Equal(items[j].Backup.CreatedAt) { + return items[i].Backup.ID > items[j].Backup.ID + } + return items[i].Backup.CreatedAt.After(items[j].Backup.CreatedAt) + }) + return items, err +} + +func (s *TransactionStore) createStateBackup(config RuntimeConfig, now time.Time) (string, error) { + unlock, err := s.lockState() + if err != nil { + return "", err + } + defer unlock() + if err := ValidateRuntimeConfig(config, s.validation); err != nil { + return "", err + } + files, err := marshalRuntimeFiles(config) + if err != nil { + return "", err + } + id, err := newTransactionID() + if err != nil { + return "", err + } + manifest := ConfigBackupManifest{ID: id, Generation: config.Generation, CreatedAt: now, Files: digestFiles(files)} + if err := s.saveState("backup", id, stateRecord{Backup: &manifest, Original: files}); err != nil { + return "", err + } + items, err := s.stateBackups() + if err != nil { + return "", err + } + for index, item := range items { + if index > 0 && (index >= configBackupLimit || now.Sub(item.Backup.CreatedAt) > configBackupMaxAge) { + if err := s.removeState("backup", item.Backup.ID); err != nil { + return "", err + } + } + } + return id, nil +} diff --git a/backend/config/transaction.go b/backend/config/transaction.go index 6c6015e..7df442e 100644 --- a/backend/config/transaction.go +++ b/backend/config/transaction.go @@ -77,6 +77,9 @@ func (s *TransactionStore) Commit(current, candidate RuntimeConfig) error { if s == nil || s.root == "" { return ErrManagerStorage } + if runtimepath.DeploymentLayout(s.root) { + return s.commitState(current, candidate) + } if current.Generation == 0 || candidate.Generation != current.Generation+1 { return ErrGenerationConflict } @@ -197,6 +200,9 @@ func (s *TransactionStore) Recover() error { if s == nil || s.root == "" { return ErrManagerStorage } + if runtimepath.DeploymentLayout(s.root) { + return s.recoverState() + } directory, err := secureManagerConfigDirectory(s.root) if err != nil { return err @@ -600,6 +606,9 @@ func (s *TransactionStore) CreateBackupIfDue(runtime RuntimeConfig, now time.Tim return "", false, err } if found && now.Before(latest.CreatedAt.Add(configBackupInterval)) { + if runtimepath.DeploymentLayout(s.root) { + return latest.ID, false, nil + } return filepath.Join(s.root, "data", "backups-v2", latest.ID), false, nil } path, err := s.createBackupAt(runtime, now) @@ -618,6 +627,9 @@ func (s *TransactionStore) createBackupAt(runtime RuntimeConfig, createdAt time. } else { createdAt = createdAt.UTC() } + if runtimepath.DeploymentLayout(s.root) { + return s.createStateBackup(runtime, createdAt) + } files, err := marshalRuntimeFiles(runtime) if err != nil { return "", err @@ -681,6 +693,13 @@ func (s *TransactionStore) latestBackup() (ConfigBackupManifest, bool, error) { if s == nil || s.root == "" { return latest, false, ErrManagerStorage } + if runtimepath.DeploymentLayout(s.root) { + items, err := s.stateBackups() + if err != nil || len(items) == 0 { + return latest, false, err + } + return *items[0].Backup, true, nil + } configDirectory, err := secureManagerConfigDirectory(s.root) if err != nil { return latest, false, err @@ -717,6 +736,18 @@ func (s *TransactionStore) LoadBackup(generation uint64) (RuntimeConfig, error) if s == nil || s.root == "" || generation == 0 { return RuntimeConfig{}, ErrNoBackup } + if runtimepath.DeploymentLayout(s.root) { + items, err := s.stateBackups() + if err != nil { + return RuntimeConfig{}, err + } + for _, item := range items { + if item.Backup.Generation == generation { + return parseRuntimeFiles(item.Original, s.validation) + } + } + return RuntimeConfig{}, ErrNoBackup + } configDirectory, err := secureManagerConfigDirectory(s.root) if err != nil { return RuntimeConfig{}, err @@ -864,6 +895,10 @@ func parseRuntimeFiles(files map[string][]byte, validation RuntimeValidationOpti return RuntimeConfig{}, err } for _, section := range requiredPartitionSections { + name, _ := FixedSectionFile(section) + if SectionDigest(files[name]) != root.Sections[section].SHA256 { + return RuntimeConfig{}, ErrConfigDigestMismatch + } if err := ValidateSectionMeta(section, sectionMeta(runtime, section), runtime.Generation); err != nil { return RuntimeConfig{}, err } diff --git a/backend/dto/parsemail/dkim.go b/backend/dto/parsemail/dkim.go index 6427efc..dae5913 100644 --- a/backend/dto/parsemail/dkim.go +++ b/backend/dto/parsemail/dkim.go @@ -10,11 +10,11 @@ import ( "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/utils/context" securefile "git.pcux.cn/xuans/Smail/utils/file" + "git.pcux.cn/xuans/Smail/utils/runtimepath" "github.com/emersion/go-msgauth/dkim" log "github.com/sirupsen/logrus" "io" "os" - "path/filepath" "strings" ) @@ -41,7 +41,7 @@ func Init() error { } func loadPrivateKey(path string) (crypto.Signer, error) { - basePath := filepath.Join(config.ROOT_PATH, "data", "dkim") + basePath := runtimepath.DKIMDirectory(config.ROOT_PATH) privateName, err := securefile.ControlledRelativePath(basePath, path) if err != nil { return nil, err diff --git a/backend/hooks/base.go b/backend/hooks/base.go index 830ee36..4251f53 100644 --- a/backend/hooks/base.go +++ b/backend/hooks/base.go @@ -275,7 +275,7 @@ func Init(serverVersion string) { return } - root, err := filepath.Abs("./plugins") + root, err := pluginRoot() if err != nil { log.Error("Plugin root resolve failed") return @@ -696,7 +696,10 @@ func PersistPluginConfig(pluginName, payload string) error { if !ok { return errors.New("plugin configuration persistence is unavailable") } - root, err := filepath.Abs("./plugins") + if bundledPlugins != nil { + return errors.New("内置插件配置必须通过 plugins.json 配置服务保存") + } + root, err := pluginRoot() if err != nil || !securePluginDirectory(root) || !securePluginDirectoryOwner(root) { return errors.New("plugin directory is unsafe") } diff --git a/backend/hooks/bundle.go b/backend/hooks/bundle.go new file mode 100644 index 0000000..5983a48 --- /dev/null +++ b/backend/hooks/bundle.go @@ -0,0 +1,52 @@ +package hooks + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "sync" +) + +var bundledPlugins fs.FS +var bundledRoot string +var bundleMu sync.Mutex + +// SetBundledPlugins 注入编译时封装的插件;插件仍作为独立进程运行,不放宽生产隔离。 +func SetBundledPlugins(files fs.FS) { + bundledPlugins = files +} + +func pluginRoot() (string, error) { + bundleMu.Lock() + defer bundleMu.Unlock() + if bundledPlugins == nil { + return filepath.Abs("./plugins") + } + if bundledRoot != "" { + return bundledRoot, nil + } + entries, err := fs.ReadDir(bundledPlugins, ".") + if err != nil || len(entries) != 3 { + return "", errors.New("内置插件包不完整") + } + // 操作系统临时沙箱不属于部署文件,避免向 config 或 data 解包可执行程序。 + root, err := os.MkdirTemp("", "smail-bundled-") + if err != nil { + return "", err + } + for _, entry := range entries { + if entry.IsDir() || !entry.Type().IsRegular() { + return "", errors.New("内置插件包含有无效文件") + } + data, err := fs.ReadFile(bundledPlugins, entry.Name()) + if err != nil || len(data) == 0 { + return "", errors.New("内置插件内容不可用") + } + if err := os.WriteFile(filepath.Join(root, entry.Name()), data, 0700); err != nil { + return "", err + } + } + bundledRoot = root + return root, nil +} diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 0f102ed..f361ba7 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -14,6 +14,9 @@ import ( // Run 启动 Smail 主服务。 func Run() error { + if err := config.InitializeDeployment(config.ROOT_PATH); err != nil { + return err + } if err := runtimepath.CheckReady(config.ROOT_PATH); err != nil { return err } diff --git a/backend/res_init/init.go b/backend/res_init/init.go index 2d97378..edf6001 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -32,6 +32,7 @@ import ( "git.pcux.cn/xuans/Smail/session" "git.pcux.cn/xuans/Smail/signal" "git.pcux.cn/xuans/Smail/utils/credential" + "git.pcux.cn/xuans/Smail/utils/runtimepath" log "github.com/sirupsen/logrus" // 导入 HTTP 客户端,用于等待服务启动。 "net/http" @@ -439,7 +440,7 @@ func stopAdminResetControl() { func dirInit() error { // 初始化与配置读写使用同一个运行根目录,不依赖启动时的工作目录。 root := filepath.Join(config.ROOT_PATH, "data") - for _, directory := range []string{filepath.Join(config.ROOT_PATH, "config"), root, filepath.Join(root, "dkim"), filepath.Join(root, "ssl")} { + for _, directory := range []string{filepath.Join(config.ROOT_PATH, "config"), root, runtimepath.DKIMDirectory(config.ROOT_PATH), runtimepath.SSLDirectory(config.ROOT_PATH)} { if err := os.MkdirAll(directory, 0700); err != nil { return fmt.Errorf("创建运行配置目录失败: %w", err) } diff --git a/backend/services/auth/auth.go b/backend/services/auth/auth.go index fb6a226..923c14e 100644 --- a/backend/services/auth/auth.go +++ b/backend/services/auth/auth.go @@ -12,6 +12,7 @@ import ( "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/password" + "git.pcux.cn/xuans/Smail/utils/runtimepath" "git.pcux.cn/xuans/Smail/utils/validation" log "github.com/sirupsen/logrus" ) @@ -117,7 +118,7 @@ func HasAuth(ctx *context.Context, email *models.Email) bool { } func DkimGen() (string, error) { - basePath := filepath.Join(config.ROOT_PATH, "data", "dkim") + basePath := runtimepath.DKIMDirectory(config.ROOT_PATH) cfg := config.CurrentConfig() if cfg == nil { return "", fmt.Errorf("runtime configuration is unavailable") diff --git a/backend/services/externalemail/vault.go b/backend/services/externalemail/vault.go index cb15240..6fa10cb 100644 --- a/backend/services/externalemail/vault.go +++ b/backend/services/externalemail/vault.go @@ -48,7 +48,7 @@ func NewVault(root string) (*Vault, error) { return nil, ErrVaultUnavailable } } - configDir, err := runtimepath.Directory(root) + configDir, err := runtimepath.SecureDirectory(root, filepath.Base(runtimepath.KeyDirectory(root))) if err != nil { return nil, ErrVaultUnavailable } diff --git a/backend/services/setup/ssl/ssl.go b/backend/services/setup/ssl/ssl.go index b9e7a73..629dfa0 100644 --- a/backend/services/setup/ssl/ssl.go +++ b/backend/services/setup/ssl/ssl.go @@ -12,6 +12,7 @@ import ( "git.pcux.cn/xuans/Smail/signal" "git.pcux.cn/xuans/Smail/utils/async" "git.pcux.cn/xuans/Smail/utils/errors" + "git.pcux.cn/xuans/Smail/utils/runtimepath" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/challenge/dns01" log "github.com/sirupsen/logrus" @@ -116,7 +117,7 @@ func SetSSL(sslType, priKey, crtKey string) error { cfg.SSL = config.SSLConfig{ Management: config.SSLManagementProject, Mode: config.ManagedSSLModeDefault, - DefaultDirectory: "data/ssl", + DefaultDirectory: runtimepath.SSLReference(config.ROOT_PATH, "data/ssl"), } if cfg.SSLType == config.SSLTypeAutoDNS { cfg.SSL.Challenge = config.SSLChallengeDNS @@ -190,12 +191,12 @@ func applyManagedSSLReference(cfg *config.Config, reference config.ManagedSSLRef cfg.SSL = config.SSLConfig{ Management: management, Mode: reference.Mode, - DefaultDirectory: "data/ssl", + DefaultDirectory: runtimepath.SSLReference(config.ROOT_PATH, "data/ssl"), CertificateRef: reference.CertificateRef, PrivateKeyRef: reference.PrivateKeyRef, } if reference.Mode == config.ManagedSSLModeUpload { - cfg.SSL.CustomDirectory = "data/ssl/managed/upload" + cfg.SSL.CustomDirectory = runtimepath.SSLReference(config.ROOT_PATH, "data/ssl/managed/upload") } else if cfg.SSLType == config.SSLTypeAutoDNS { cfg.SSL.Challenge = config.SSLChallengeDNS } else { diff --git a/backend/services/setup/ssl/storage.go b/backend/services/setup/ssl/storage.go index 0c791be..e13fc1c 100644 --- a/backend/services/setup/ssl/storage.go +++ b/backend/services/setup/ssl/storage.go @@ -10,6 +10,7 @@ import ( "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/utils/atomicfile" securefile "git.pcux.cn/xuans/Smail/utils/file" + "git.pcux.cn/xuans/Smail/utils/runtimepath" ) func writeCertificateBundle(cfg *config.Config, privateKey, certificate, issuer []byte) error { @@ -31,7 +32,7 @@ func writeCertificateBundle(cfg *config.Config, privateKey, certificate, issuer return err } defer workspaceRoot.Close() - baseRelative := filepath.Join("data", "ssl") + baseRelative := filepath.FromSlash(runtimepath.SSLReference(rootPath, "data/ssl")) if err := ensureSecureRootDirectory(workspaceRoot, baseRelative); err != nil { return err } @@ -57,7 +58,7 @@ func writeCertificateBundle(cfg *config.Config, privateKey, certificate, issuer } relativePath, err := securefile.ControlledRelativePath(basePath, item.path) if err != nil { - return errors.New("SSL file path must stay inside data/ssl") + return errors.New("证书文件必须位于受保护的 SSL 目录中") } if _, exists := seen[relativePath]; exists { return errors.New("SSL file paths must be unique") diff --git a/backend/utils/credential/encryption.go b/backend/utils/credential/encryption.go index bb454af..f59d526 100644 --- a/backend/utils/credential/encryption.go +++ b/backend/utils/credential/encryption.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "errors" "git.pcux.cn/xuans/Smail/utils/atomicfile" + "git.pcux.cn/xuans/Smail/utils/runtimepath" "io" "os" "path/filepath" @@ -85,7 +86,7 @@ func loadOrCreateKey(root string) ([]byte, error) { if !os.IsNotExist(err) { return nil, err } - directory := filepath.Join(root, "data") + directory := runtimepath.KeyDirectory(root) if err := os.MkdirAll(directory, 0700); err != nil { return nil, err } @@ -158,13 +159,16 @@ func newCredentialKeyTemporaryName() (string, error) { } func readKey(root string) ([]byte, error) { + if err := runtimepath.CheckKeyLayout(root); err != nil { + return nil, err + } // 旧密钥尚未迁移时拒绝生成新密钥,避免现有密文永久失去读取能力。 if _, err := os.Lstat(filepath.Join(root, "config", "credential.key")); err == nil { return nil, errors.New("凭据密钥尚未迁移到 data") } else if !os.IsNotExist(err) { return nil, err } - directory := filepath.Join(root, "data") + directory := runtimepath.KeyDirectory(root) if err := validateKeyDirectory(directory); err != nil { return nil, err } diff --git a/backend/utils/runtimepath/deployment.go b/backend/utils/runtimepath/deployment.go new file mode 100644 index 0000000..f16acbe --- /dev/null +++ b/backend/utils/runtimepath/deployment.go @@ -0,0 +1,84 @@ +package runtimepath + +import ( + "errors" + "os" + "path/filepath" +) + +// CheckKeyLayout 拒绝同时存在新旧密钥,避免误用另一套加密材料。 +func CheckKeyLayout(root string) error { + legacy, modern := false, false + for _, name := range []string{"credential.key", "external-email-encryption.key", "external-email-fingerprint.key"} { + if _, err := os.Lstat(filepath.Join(root, "data", name)); err == nil { + legacy = true + } 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) { + return err + } + } + if legacy && modern { + return errors.New("新旧目录同时存在密钥,必须先人工确认迁移,不能自动选择") + } + return nil +} + +// DeploymentLayout 识别已迁移部署,旧部署继续只读原路径,不能静默生成替代密钥。 +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"} { + info, err := os.Lstat(filepath.Join(root, filepath.FromSlash(name))) + if err == nil && info.Mode().IsRegular() { + return true + } + } + return false +} + +func SecureDirectory(root, name string) (string, error) { + directory := filepath.Join(root, name) + if err := os.Mkdir(directory, 0700); err != nil && !os.IsExist(err) { + return "", err + } + if err := safeDirectory(directory); err != nil { + return "", err + } + return directory, os.Chmod(directory, 0700) +} + +func KeyDirectory(root string) string { + if DeploymentLayout(root) { + return filepath.Join(root, "ssl") + } + return filepath.Join(root, "data") +} + +func SSLReference(root, value string) string { + value = LegacyReference(value) + if !DeploymentLayout(root) { + return value + } + for _, prefix := range []string{"data/ssl", "data/dkim"} { + if value == prefix || len(value) > len(prefix) && value[:len(prefix)+1] == prefix+"/" { + if prefix == "data/dkim" { + return "ssl/dkim" + value[len(prefix):] + } + return "ssl" + value[len(prefix):] + } + } + return value +} + +func SSLDirectory(root string) string { + return filepath.Join(root, filepath.FromSlash(SSLReference(root, "data/ssl"))) +} + +func DKIMDirectory(root string) string { + return filepath.Join(root, filepath.FromSlash(SSLReference(root, "data/dkim"))) +} diff --git a/backend/utils/runtimepath/layout.go b/backend/utils/runtimepath/layout.go index 3974d6d..5409496 100644 --- a/backend/utils/runtimepath/layout.go +++ b/backend/utils/runtimepath/layout.go @@ -150,6 +150,9 @@ func safeDirectory(path string) error { // CheckReady 防止遗漏迁移时创建空账号库或重新生成加密密钥。 func CheckReady(root string) error { + if err := CheckKeyLayout(root); err != nil { + return err + } if _, err := os.Lstat(filepath.Join(root, "data", migrationLock)); err == nil || !os.IsNotExist(err) { return errors.New("运行目录迁移尚未结束,请保留现场并核对迁移锁") } @@ -189,14 +192,21 @@ func CheckReady(root string) error { required = append(required, config.Database.LocalRef) } for _, name := range required { + base := filepath.Join(root, "data") + if strings.HasSuffix(name, ".key") { + base = KeyDirectory(root) + } + if err := safeDirectory(base); err != nil { + return err + } parent := filepath.Dir(name) for parent != "." { - if err := safeDirectory(filepath.Join(root, "data", parent)); err != nil { + if err := safeDirectory(filepath.Join(base, parent)); err != nil { return err } parent = filepath.Dir(parent) } - info, err := os.Lstat(filepath.Join(root, "data", name)) + info, err := os.Lstat(filepath.Join(base, name)) if err != nil || !info.Mode().IsRegular() || info.Size() == 0 { return fmt.Errorf("已初始化部署缺少必要运行文件,拒绝重新创建:%s", name) } diff --git a/deploy/build-state.mjs b/deploy/build-state.mjs new file mode 100644 index 0000000..9e66d1a --- /dev/null +++ b/deploy/build-state.mjs @@ -0,0 +1,91 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { lstat, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { runtimeRoot, sourceRoot } from './runtime-config.mjs' + +const receiptPath = path.join(sourceRoot, 'out', 'build-state.json') +const skipped = new Set(['node_modules', 'dist', 'dist-ssr', 'output', 'bundled', '__pycache__', '.git']) +const sourceDirectories = ['backend', 'frontend', 'plugins/pmail_telegram_push', 'deploy'] + +export async function sourceFingerprint(root = sourceRoot) { + const hash = createHash('sha256') + async function visit(relative) { + const entries = await readdir(path.join(root, relative), { withFileTypes: true }) + entries.sort((left, right) => left.name.localeCompare(right.name, 'en')) + for (const entry of entries) { + if (skipped.has(entry.name) || entry.name.startsWith('.tmp-') || entry.name === 'auto-imports.d.ts' || entry.name === 'components.d.ts') continue + const name = `${relative}/${entry.name}` + if (entry.isDirectory()) { + await visit(name) + } else if (!/\.(exe|log|pid|db|tsbuildinfo)$/.test(entry.name)) { + if (!entry.isFile()) throw new Error(`源码包含链接或特殊文件:${name}`) + hash.update(name).update('\0').update(await readFile(path.join(root, name))).update('\0') + } + } + } + for (const directory of sourceDirectories) await visit(directory) + return hash.digest('hex') +} + +export async function fileDigest(filename) { + const info = await lstat(filename) + if (!info.isFile() || info.isSymbolicLink() || info.size === 0) throw new Error('编译产物为空或不是普通文件') + return createHash('sha256').update(await readFile(filename)).digest('hex') +} + +const artifacts = [ + ['out/backend/smail.exe', 'smail.exe'], + ['out/backend/smail-admin.exe', 'smail-admin.exe'], + ['out/plugins/spam_block.exe', null], + ['out/plugins/pmail_telegram_push.exe', null], + ['out/plugins/wechat_push.exe', null] +] + +export async function sealBuild(expectedFingerprint) { + const actual = await sourceFingerprint() + if (!expectedFingerprint || actual !== expectedFingerprint) throw new Error('编译期间源码发生变化,请重新编译,拒绝部署不一致产物') + const files = await Promise.all(artifacts.map(async ([source, target]) => ({ + source, target, sha256: await fileDigest(path.join(sourceRoot, source)) + }))) + const receipt = { + sourceFingerprint: actual, + gitHash: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: sourceRoot, encoding: 'utf8', windowsHide: true }).trim(), + builtAt: new Date().toISOString(), + files + } + await mkdir(path.dirname(receiptPath), { recursive: true }) + await writeFile(`${receiptPath}.tmp`, JSON.stringify(receipt, null, 2) + '\n') + await rename(`${receiptPath}.tmp`, receiptPath) + return receipt +} + +export async function verifyBuild({ deployed = false } = {}) { + const receipt = JSON.parse(await readFile(receiptPath, 'utf8')) + if (receipt.sourceFingerprint !== await sourceFingerprint()) throw new Error('源码已变化,必须重新编译后才能启动') + if (JSON.stringify(receipt.files.map(item => [item.source, item.target])) !== JSON.stringify(artifacts)) { + throw new Error('编译清单不完整,请重新编译') + } + for (const item of receipt.files) { + if (await fileDigest(path.join(sourceRoot, item.source)) !== item.sha256) throw new Error(`编译产物校验失败:${item.source}`) + if (deployed && item.target && await fileDigest(path.join(runtimeRoot, item.target)) !== item.sha256) { + throw new Error(`部署产物不是最新编译结果:${item.target}`) + } + } + return receipt +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + switch (process.argv[2]) { + case 'fingerprint': console.log(await sourceFingerprint()); break + case 'seal': await sealBuild(process.argv[3]); break + case 'verify': await verifyBuild({ deployed: process.argv.includes('--deployed') }); break + default: throw new Error('用法:build-state.mjs fingerprint|seal|verify') + } + } catch (error) { + console.error(error.message) + process.exitCode = 1 + } +} diff --git a/deploy/build.ps1 b/deploy/build.ps1 index 33d1466..4252e5d 100644 --- a/deploy/build.ps1 +++ b/deploy/build.ps1 @@ -2,24 +2,63 @@ $ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot +$fingerprint = & node (Join-Path $PSScriptRoot 'build-state.mjs') fingerprint +if ($LASTEXITCODE -ne 0) { throw '无法计算源码指纹' } $output = Join-Path $root 'out\backend' New-Item -ItemType Directory -Path $output -Force | Out-Null if (-not $SkipFrontend) { + # 只归档确定的编译目录,不删除文件;防止历史散列资源持续进入 Go 嵌入包。 + $archive = Join-Path $root ('out\build-history\' + (Get-Date -Format 'yyyyMMdd-HHmmss-fff')) + $generated = @{ + 'frontend' = 'out\frontend' + 'embedded-frontend' = 'backend\listen\http_server\dist' + 'telegram-ui' = 'out\plugins\telegram-ui' + 'embedded-telegram' = 'plugins\pmail_telegram_push\hook\dist' + 'embedded-plugins' = 'backend\cmd\smail\bundled' + } + foreach ($name in $generated.Keys) { + $source = [IO.Path]::GetFullPath((Join-Path $root $generated[$name])) + $target = [IO.Path]::GetFullPath((Join-Path $archive $name)) + $boundary = [IO.Path]::GetFullPath($root).TrimEnd('\') + '\' + if (-not $source.StartsWith($boundary, [StringComparison]::OrdinalIgnoreCase) -or -not $target.StartsWith($boundary, [StringComparison]::OrdinalIgnoreCase)) { + throw '编译归档路径越过项目目录,已停止' + } + if (Test-Path -LiteralPath $source) { + $info = Get-Item -LiteralPath $source -Force + if (-not $info.PSIsContainer -or $info.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw '编译目录无效或为链接,禁止归档' } + New-Item -ItemType Directory -Path $archive -Force | Out-Null + Move-Item -LiteralPath $source -Destination $target -ErrorAction Stop + } + } Push-Location (Join-Path $root 'frontend') try { & npm.cmd run build if ($LASTEXITCODE -ne 0) { throw '前端构建失败' } } finally { Pop-Location } } +& node (Join-Path $PSScriptRoot 'build-plugins.mjs') --dev +if ($LASTEXITCODE -ne 0) { throw '插件构建失败' } +$bundle = Join-Path $root 'backend\cmd\smail\bundled' +New-Item -ItemType Directory -Path $bundle -Force | Out-Null +foreach ($name in @('spam_block.exe', 'pmail_telegram_push.exe', 'wechat_push.exe')) { + Copy-Item -LiteralPath (Join-Path $root "out\plugins\$name") -Destination (Join-Path $bundle $name) -ErrorAction Stop +} Push-Location (Join-Path $root 'backend') try { - & go build -o (Join-Path $output 'smail-dev.exe') . + $gitHash = (& git rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0) { throw '无法读取构建提交信息' } + $buildTime = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + $goVersion = (& go version).Trim() + $flags = "-s -w -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.Version=dev-$($fingerprint.Substring(0, 12))' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.GitHash=$gitHash' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.BuildTime=$buildTime' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.GoVersion=$goVersion'" + & go build -tags bundled -trimpath -ldflags $flags -o (Join-Path $output 'smail.exe') ./cmd/smail if ($LASTEXITCODE -ne 0) { throw '开发后端构建失败' } - & go build -o (Join-Path $output 'smail-admin.exe') ./cmd/smail-admin + & go build -trimpath -ldflags $flags -o (Join-Path $output 'smail-admin.exe') ./cmd/smail-admin if ($LASTEXITCODE -ne 0) { throw '管理工具构建失败' } & go build -o (Join-Path $output 'smail-config.exe') ./cmd/smail-config if ($LASTEXITCODE -ne 0) { throw '配置工具构建失败' } } finally { Pop-Location } -& node (Join-Path $PSScriptRoot 'build-plugins.mjs') --dev -if ($LASTEXITCODE -ne 0) { throw '插件构建失败' } +if (-not $SkipFrontend) { + & node (Join-Path $PSScriptRoot 'build-state.mjs') seal $fingerprint + if ($LASTEXITCODE -ne 0) { throw '编译产物封存失败,禁止部署或启动旧包' } +} Write-Output '构建完成:out/frontend、out/backend 和 out/plugins,包含三个内置插件。正式发行请使用 Makefile 的跨平台构建目标。' diff --git a/deploy/bundle-plugins.mjs b/deploy/bundle-plugins.mjs new file mode 100644 index 0000000..43d6404 --- /dev/null +++ b/deploy/bundle-plugins.mjs @@ -0,0 +1,23 @@ +import { cp, lstat, mkdir, readdir, rename } from 'node:fs/promises' +import path from 'node:path' +import { buildPlan, root } from './build-plugins.mjs' + +const plan = buildPlan({ target: process.argv[2] }) +const destination = path.join(root, 'backend/cmd/smail/bundled') +for (const item of plan) { + const info = await lstat(item.output) + if (!info.isFile() || info.isSymbolicLink()) throw new Error('待嵌入插件不是普通文件') +} +try { + await readdir(destination) + const history = path.join(root, 'out/build-history', `plugins-${Date.now()}`) + await mkdir(path.dirname(history), { recursive: true }) + const info = await lstat(destination) + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('嵌入目录无效') + await rename(destination, history) +} catch (error) { + if (error.code !== 'ENOENT') throw error +} +await mkdir(destination, { recursive: true }) +for (const item of plan) await cp(item.output, path.join(destination, path.basename(item.output)), { force: false }) +console.log('三个插件已准备嵌入主程序。') diff --git a/deploy/check-runtime.mjs b/deploy/check-runtime.mjs new file mode 100644 index 0000000..6d4bfa7 --- /dev/null +++ b/deploy/check-runtime.mjs @@ -0,0 +1,9 @@ +import { assertDeploymentLayout } from './runtime-config.mjs' + +try { + assertDeploymentLayout() + console.log('模拟部署配置、证书与账号库路径检查通过。') +} catch (error) { + console.error(`yunx-test 尚未完成安全迁移,禁止启动:${error.message}`) + process.exitCode = 1 +} diff --git a/deploy/deploy-runtime.ps1 b/deploy/deploy-runtime.ps1 new file mode 100644 index 0000000..ac763fc --- /dev/null +++ b/deploy/deploy-runtime.ps1 @@ -0,0 +1,46 @@ +param() + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$runtime = Join-Path $root 'yunx-test' +$boundary = [IO.Path]::GetFullPath($root).TrimEnd('\') + '\' +foreach ($directory in @($runtime, (Join-Path $root 'out'))) { + if (-not [IO.Path]::GetFullPath($directory).StartsWith($boundary, [StringComparison]::OrdinalIgnoreCase)) { throw '部署目录越过项目边界' } + if (Test-Path -LiteralPath $directory) { + $info = Get-Item -LiteralPath $directory -Force + if (-not $info.PSIsContainer -or $info.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw '部署目录不能是链接或文件' } + } +} +$binary = Join-Path $runtime 'smail.exe' +$running = Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -eq $binary } +if ($running) { throw 'yunx-test 后端仍在运行,请先停止后端再部署,禁止覆盖正在运行的程序' } +New-Item -ItemType Directory -Path (Join-Path $root 'out') -Force | Out-Null +$lock = $null +try { + $lock = [IO.File]::Open((Join-Path $root 'out\deployment.lock'), [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + +# 完整构建成功后才允许更新运行包,失败时不回退使用历史产物。 +& (Join-Path $PSScriptRoot 'build.ps1') +& node (Join-Path $PSScriptRoot 'build-state.mjs') verify +if ($LASTEXITCODE -ne 0) { throw '编译产物校验失败,未部署' } + +New-Item -ItemType Directory -Path $runtime -Force | Out-Null +$receipt = Get-Content -LiteralPath (Join-Path $root 'out\build-state.json') -Encoding UTF8 -Raw | ConvertFrom-Json +$backup = Join-Path $root ('out\deployment-history\' + (Get-Date -Format 'yyyyMMdd-HHmmss-fff')) +foreach ($item in $receipt.files) { + if (-not $item.target) { continue } + $target = Join-Path $runtime $item.target + if (Test-Path -LiteralPath $target) { + $info = Get-Item -LiteralPath $target -Force + if ($info.Attributes -band [IO.FileAttributes]::ReparsePoint -or $info.PSIsContainer) { throw '部署程序不能是链接或目录' } + New-Item -ItemType Directory -Path $backup -Force | Out-Null + Copy-Item -LiteralPath $target -Destination (Join-Path $backup $item.target) -ErrorAction Stop + } + Copy-Item -LiteralPath (Join-Path $root $item.source) -Destination $target -ErrorAction Stop +} +& node (Join-Path $PSScriptRoot 'build-state.mjs') verify --deployed +if ($LASTEXITCODE -ne 0) { throw '部署校验失败,禁止启动;旧程序备份保留在 out/deployment-history' } +Write-Output '最新编译包已部署到 yunx-test;配置、证书、数据库未被构建过程覆盖。' +} finally { + if ($lock) { $lock.Dispose() } +} diff --git a/deploy/package.mjs b/deploy/package.mjs index 37a56e5..e223e63 100644 --- a/deploy/package.mjs +++ b/deploy/package.mjs @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url' import { buildPlan, root, targets } from './build-plugins.mjs' export function packagePlan(target) { - const pluginPlan = buildPlan({ target }) + buildPlan({ target }) const [goos, goarch] = target.split('_') const extension = goos === 'windows' ? '.exe' : '' const suffix = `${goos === 'darwin' ? 'mac' : goos}_${goarch}${extension}` @@ -13,9 +13,7 @@ export function packagePlan(target) { ...['smail', 'smail-admin'].map(name => ({ source: path.join(root, 'out', 'backend', `${name}_${suffix}`), target: path.join(directory, `${name}${extension}`) - })), - ...pluginPlan.map(item => ({ source: item.output, target: path.join(directory, 'plugins', path.basename(item.output)) })), - { source: path.join(root, 'README.md'), target: path.join(directory, 'README.md') } + })) ] } @@ -28,7 +26,9 @@ export async function packageRelease(selectedTargets = targets) { await copyFile(item.source, item.target) } for (const target of selectedTargets) { - await writeFile(path.join(root, 'out', 'package', target, 'release.json'), JSON.stringify({ + for (const name of ['config', 'ssl', 'data', 'log']) await mkdir(path.join(root, 'out', 'package', target, name), { recursive: true }) + await mkdir(path.join(root, 'out', 'release-receipts'), { recursive: true }) + await writeFile(path.join(root, 'out', 'release-receipts', `${target}.json`), JSON.stringify({ version: process.env.SMAIL_VERSION || 'dev', gitHash: process.env.GIT_HASH || '', protocolVersion: 1, target, plugins: buildPlan({ target }).map(item => item.id), createdAt: new Date().toISOString() }, null, 2) + '\n') diff --git a/deploy/runtime-config.mjs b/deploy/runtime-config.mjs new file mode 100644 index 0000000..ad1101a --- /dev/null +++ b/deploy/runtime-config.mjs @@ -0,0 +1,68 @@ +import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export const sourceRoot = fileURLToPath(new URL('../', import.meta.url)) +export const runtimeRoot = path.join(sourceRoot, 'yunx-test') + +export function assertDeploymentLayout(root = runtimeRoot) { + const allowed = new Set(['config', 'data', 'ssl', 'log', 'smail', 'smail-admin', 'smail.exe', 'smail-admin.exe']) + for (const item of readdirSync(root, { withFileTypes: true })) { + if (!allowed.has(item.name) || item.isSymbolicLink()) throw new Error('部署根目录包含额外项目或链接') + } + const names = ['config.json', 'smtp.json', 'email.json', 'ssl.json', 'plugins.json'] + if (JSON.stringify(readdirSync(path.join(root, 'config')).sort()) !== JSON.stringify(names.sort())) { + throw new Error('config 只能包含指定的五个配置文件') + } + const config = JSON.parse(readFileSync(resolveRuntimeFile(root, 'config/config.json'), 'utf8')) + for (const name of ['smtp', 'email', 'ssl', 'plugins']) { + 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}`) + } + if (config.database?.type === 'sqlite') resolveRuntimeFile(root, `data/${config.database.localRef}`) + } + const ssl = JSON.parse(readFileSync(resolveRuntimeFile(root, 'config/ssl.json'), 'utf8')).tls + for (const reference of [ssl?.certificateRef, ssl?.privateKeyRef]) { + if (!reference?.startsWith('ssl/')) throw new Error('证书引用尚未迁移到 ssl,拒绝使用旧目录启动') + resolveRuntimeFile(root, reference) + } + return config +} + +export function resolveRuntimeFile(root, reference) { + if (typeof reference !== 'string' || !reference || path.isAbsolute(reference)) { + throw new Error('运行文件必须使用部署目录内的相对引用') + } + const resolvedRoot = realpathSync(root) + const absolute = path.resolve(root, reference) + const info = lstatSync(absolute) + if (!info.isFile() || info.isSymbolicLink()) throw new Error('运行文件必须是普通文件') + const target = realpathSync(absolute) + const relative = path.relative(resolvedRoot, target) + if (!relative || relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) { + throw new Error('运行文件不能越过部署目录') + } + return target +} + +// 仅供 Node/Vite 服务端读取,不能把包含凭据的配置对象暴露给浏览器。 +export function readDevelopmentRuntime(root = runtimeRoot) { + const read = name => JSON.parse(readFileSync(resolveRuntimeFile(root, `config/${name}.json`), 'utf8')) + const config = read('config') + const ssl = read('ssl') + const webDomain = config.domains?.web + if (typeof webDomain !== 'string' || !/^[a-z0-9.-]+(?::\d+)?$/i.test(webDomain)) { + throw new Error('请先在 yunx-test/config/config.json 配置有效的 Web 域名') + } + const certificatePath = resolveRuntimeFile(root, ssl.tls?.certificateRef) + const privateKeyPath = resolveRuntimeFile(root, ssl.tls?.privateKeyRef) + return { + origin: `https://${webDomain}`, + certificatePath, + privateKeyPath, + generation: config.generation + } +} diff --git a/deploy/start-dev.ps1 b/deploy/start-dev.ps1 index a2488ec..f8a2a52 100644 --- a/deploy/start-dev.ps1 +++ b/deploy/start-dev.ps1 @@ -1,11 +1,12 @@ param( - [ValidateSet('backend', 'frontend')][string]$Service = 'backend', - [switch]$SkipBuild + [ValidateSet('backend', 'frontend', 'all')][string]$Service = 'all', + [switch]$Restart ) $ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot -$logs = Join-Path $root 'test-log\runtime' +$runtime = Join-Path $root 'yunx-test' +$logs = Join-Path $runtime 'log' New-Item -ItemType Directory -Path $logs -Force | Out-Null $stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' $previousRoot = $env:SMAIL_ROOT @@ -13,8 +14,21 @@ $previousHTTPPortOverride = $env:SMAIL_HTTP_PORT_OVERRIDE $previousHTTPTLSModeOverride = $env:SMAIL_HTTP_TLS_MODE_OVERRIDE $previousSetupPort = $env:setup_port try { - if ($Service -eq 'backend') { - $binary = Join-Path $root 'out\backend\smail-dev.exe' + & node (Join-Path $PSScriptRoot 'check-runtime.mjs') + if ($LASTEXITCODE -ne 0) { throw '请先完成 yunx-test 运行数据迁移,不能自动创建另一份账号库' } + if ($Restart) { + # 只停止本部署的已确认进程,不能按进程名称或端口盲目结束其他项目。 + $expected = Join-Path $runtime 'smail.exe' + $vitePath = Join-Path $root 'frontend\node_modules\vite\bin\vite.js' + $processes = Get-CimInstance Win32_Process | Where-Object { + ($Service -in @('backend', 'all') -and $_.ExecutablePath -eq $expected) -or + ($Service -in @('frontend', 'all') -and $_.Name -eq 'node.exe' -and $_.CommandLine.Contains($vitePath)) + } + foreach ($item in $processes) { Stop-Process -Id $item.ProcessId -ErrorAction Stop } + Start-Sleep -Seconds 2 + } + if ($Service -in @('backend', 'all')) { + $binary = Join-Path $runtime 'smail.exe' if (Get-NetTCPConnection -State Listen -LocalPort 5201 -ErrorAction SilentlyContinue) { throw '5201 端口已占用,请先确认并停止旧后端' } $running = Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath -eq $binary @@ -27,32 +41,31 @@ try { throw 'backend/config 中仍有运行文件,请先停止后端并完成根目录 config 迁移' } } - # 默认先编译当前工作区,不再把历史 smail-dev.exe 当成最新后端。 - # 不编译前端,保留 Vite 热更新;缺少首次嵌入资源时要求显式完整构建。 - if (-not $SkipBuild) { - & (Join-Path $PSScriptRoot 'build.ps1') -SkipFrontend - } - if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) { throw '请先运行 deploy/build.ps1 编译后端' } + & (Join-Path $PSScriptRoot 'deploy-runtime.ps1') + & node (Join-Path $PSScriptRoot 'build-state.mjs') verify --deployed + if ($LASTEXITCODE -ne 0) { throw '运行包不是最新编译结果,拒绝启动' } # SMAIL_ROOT 是 config 的父目录,不能指向 config 本身或编译输出目录。 - $env:SMAIL_ROOT = $root + $env:SMAIL_ROOT = $runtime $env:SMAIL_HTTP_PORT_OVERRIDE = '5201' $env:SMAIL_HTTP_TLS_MODE_OVERRIDE = 'proxy' $env:setup_port = '5201' $arguments = @('--dev-disable-admin-reset') - Write-Output '开发启动:后端 API 仅监听 5201;沿用 config/config.json 和现有数据库;明确停用管理员恢复通道,不请求权限提升。' - if ($SkipBuild) { Write-Warning '已跳过编译,请自行确认二进制与当前源码一致。' } - # 插件从 out/plugins 加载;SMAIL_ROOT 下 config 保存配置,data 保存账号、密钥和证书。 + Write-Output '开发启动:后端监听 5201,运行根目录固定为 yunx-test;明确停用管理员恢复通道,不请求权限提升。' + # 插件产物仍须核验;插件打包迁移未完成时不能省略这些构建输入。 foreach ($name in @('spam_block.exe', 'pmail_telegram_push.exe', 'wechat_push.exe')) { if (-not (Test-Path -LiteralPath (Join-Path $root "out\plugins\$name") -PathType Leaf)) { throw '内置插件产物不完整,请先运行 deploy/build.ps1' } } - $workingDirectory = Join-Path $root 'out' + $workingDirectory = $runtime } else { foreach ($port in @(80, 443)) { if (Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue) { throw "$port 端口已占用,请先确认并停止旧前端或旧后端" } } $binary = (Get-Command node -ErrorAction Stop).Source + & node (Join-Path $PSScriptRoot 'build-state.mjs') verify --deployed + if ($LASTEXITCODE -ne 0) { throw '请先使用 -Service all 或 backend 重新编译部署,前端不能搭配旧后端启动' } + $env:SMAIL_ROOT = $runtime $vite = Join-Path $root 'frontend\node_modules\vite\bin\vite.js' if (-not (Test-Path -LiteralPath $vite)) { throw '请先安装 frontend 的依赖' } $arguments = @(('"{0}"' -f $vite), '--host', '127.0.0.1', '--port', '443', '--strictPort') @@ -65,7 +78,7 @@ try { Start-Sleep -Seconds 3 $process.Refresh() if ($process.HasExited) { throw "服务启动失败;标准输出:$stdout;错误日志:$stderr" } - Write-Output "$Service 已启动,进程编号:$($process.Id);日志目录:test-log/runtime" + Write-Output "$Service 已启动,进程编号:$($process.Id);日志目录:yunx-test/log" Write-Output "运行文件:$binary;标准输出:$stdout;错误日志:$stderr" } finally { $env:SMAIL_ROOT = $previousRoot @@ -73,3 +86,6 @@ try { $env:SMAIL_HTTP_TLS_MODE_OVERRIDE = $previousHTTPTLSModeOverride $env:setup_port = $previousSetupPort } +if ($Service -eq 'all') { + & $PSCommandPath -Service frontend +} diff --git a/deploy/start-production.ps1 b/deploy/start-production.ps1 new file mode 100644 index 0000000..d8c58de --- /dev/null +++ b/deploy/start-production.ps1 @@ -0,0 +1,35 @@ +param([switch]$Restart) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$runtime = Join-Path $root 'yunx-test' +$binary = Join-Path $runtime 'smail.exe' +& node (Join-Path $PSScriptRoot 'check-runtime.mjs') +if ($LASTEXITCODE -ne 0) { throw '模拟部署目录未准备完成' } +if ($Restart) { + Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -eq $binary } | ForEach-Object { + Stop-Process -Id $_.ProcessId -ErrorAction Stop + } + Start-Sleep -Seconds 2 +} +& (Join-Path $PSScriptRoot 'deploy-runtime.ps1') +& node (Join-Path $PSScriptRoot 'build-state.mjs') verify --deployed +if ($LASTEXITCODE -ne 0) { throw '运行包不是最新源码的构建结果' } +$previous = @{} +foreach ($name in @('SMAIL_ROOT', 'SMAIL_HTTP_PORT_OVERRIDE', 'SMAIL_HTTP_TLS_MODE_OVERRIDE', 'setup_port', 'SMAIL_DEV_DISABLE_ADMIN_RESET')) { + $previous[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') + [Environment]::SetEnvironmentVariable($name, $null, 'Process') +} +try { + $env:SMAIL_ROOT = $runtime + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' + $process = Start-Process -FilePath $binary -WorkingDirectory $runtime -WindowStyle Hidden -PassThru ` + -RedirectStandardOutput (Join-Path $runtime "log\production-$stamp.stdout.log") ` + -RedirectStandardError (Join-Path $runtime "log\production-$stamp.stderr.log") + Start-Sleep -Seconds 3 + $process.Refresh() + if ($process.HasExited) { throw '生产模拟启动失败,请检查 yunx-test/log;不会自动豁免管理员恢复通道保护' } + Write-Output "生产模拟已启动:$($process.Id),使用嵌入前端和 yunx-test 配置,管理员恢复通道保持保护。" +} finally { + foreach ($name in $previous.Keys) { [Environment]::SetEnvironmentVariable($name, $previous[$name], 'Process') } +} diff --git a/docs/模拟部署与最新构建.md b/docs/模拟部署与最新构建.md new file mode 100644 index 0000000..8e76409 --- /dev/null +++ b/docs/模拟部署与最新构建.md @@ -0,0 +1,45 @@ +# 模拟部署与最新构建 + +## 已确认要求 + +- 源码继续放在 `backend/`、`frontend/`。 +- 生产、开发模拟部署均使用 `yunx-test/`,禁止把 `out/` 当运行目录。 +- 配置固定为 `config/` 中的五个 JSON;证书及加密密钥归 `ssl/`;数据库归 `data/`;日志归 `log/`。 +- 保留独立的 `smail`、`smail-admin`。Windows 使用 `.exe` 后缀。 +- 前端开发仍使用原源码目录的 Vite 热更新。 +- 不清空现有业务库,不删除原证书、密钥及历史备份。 + +## 当前任务状态 + +- `[✓]` 修改前的文档基线已提交并推送。 +- `[✓]` 源码指纹和产物 SHA-256 校验已加入构建、部署入口。 +- `[x]` 开发启动入口固定使用 `yunx-test`,移除跳过编译选项,等待迁移后运行复审。 +- `[x]` Vite 仅在服务端从 `yunx-test/config` 读取域名、证书引用,等待真实页面复审。 +- `[~]` 完整构建、最新产物部署和回归验证。 +- `[ ]` 配置事务与历史备份改存状态数据库。 +- `[ ]` 密钥、TLS、DKIM、ACME 和审计日志迁到最终目录。 +- `[ ]` 内置插件合并进发布产物并保持隔离机制。 +- `[ ]` 停服备份、无损迁移到 `yunx-test`。 +- `[ ]` 新目录开发、生产模拟运行和浏览器验收。 + +## 已实现入口 + +完整构建并部署二进制,不覆盖配置和数据库: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File deploy/deploy-runtime.ps1 +``` + +完成运行数据迁移后,启动前后端: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File deploy/start-dev.ps1 -Service all +``` + +后端启动总是先完整编译、部署和校验。单独启动前端会校验当前源码与部署产物,不允许搭配过期后端。缺少迁移后的配置、账号库或密钥时拒绝启动,不会创建另一份空库。 + +构建收据保存在 `out/build-state.json`,不混入部署根目录。已有程序被覆盖前备份至 `out/deployment-history/`。历史前端编译目录归档至 `out/build-history/`,不删除生成物,也不将历次散列资源重复嵌入新程序。 + +## 验收边界 + +构建通过不代表目录迁移、登录持久化、插件、TLS 和管理员恢复通道均已验收。迁移完成前,不能使用旧布局的测试结果证明新布局正确。 diff --git a/frontend/.env.development b/frontend/.env.development index b7318a1..3a07dc3 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,5 +1 @@ VITE_DEV_BACKEND_URL=http://127.0.0.1:5201 -VITE_DEV_BACKEND_ORIGIN=https://xuans.com -VITE_DEV_FRONTEND_ORIGIN=https://xuans.com -SMAIL_DEV_TLS_CERTIFICATE=../data/ssl/xuans.com.crt -SMAIL_DEV_TLS_PRIVATE_KEY=../data/ssl/xuans.com.key diff --git a/frontend/vite.config.js b/frontend/vite.config.js index cb21e53..c305cd7 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,13 +1,13 @@ /* eslint-env node */ import { readFileSync } from 'node:fs' import { createServer as createHTTPServer } from 'node:http' -import path from 'node:path' import { fileURLToPath, URL } from 'node:url' import { defineConfig, loadEnv } from 'vite' import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' import vue from '@vitejs/plugin-vue' +import { readDevelopmentRuntime } from '../deploy/runtime-config.mjs' const developmentHTTPRedirect = (frontendOrigin, host = '127.0.0.1', port = 80) => ({ name: 'smail-development-http-redirect', @@ -35,10 +35,9 @@ const developmentHTTPRedirect = (frontendOrigin, host = '127.0.0.1', port = 80) export default defineConfig(({ mode, command }) => { const env = loadEnv(mode, process.cwd(), '') const backendUrl = (env.VITE_DEV_BACKEND_URL || 'http://127.0.0.1:5201').replace(/\/$/, '') - const backendOrigin = (env.VITE_DEV_BACKEND_ORIGIN || 'https://xuans.com').replace(/\/$/, '') - const frontendOrigin = (env.VITE_DEV_FRONTEND_ORIGIN || 'https://xuans.com').replace(/\/$/, '') - const certificatePath = path.resolve(process.cwd(), env.SMAIL_DEV_TLS_CERTIFICATE || '../data/ssl/xuans.com.crt') - const privateKeyPath = path.resolve(process.cwd(), env.SMAIL_DEV_TLS_PRIVATE_KEY || '../data/ssl/xuans.com.key') + const runtime = command === 'serve' ? readDevelopmentRuntime() : undefined + const backendOrigin = runtime?.origin + const frontendOrigin = runtime?.origin return { plugins: [ @@ -62,8 +61,8 @@ export default defineConfig(({ mode, command }) => { strictPort: true, // 生产构建不依赖本机开发证书,只有开发服务器启动时才读取私钥。 https: command === 'serve' ? { - cert: readFileSync(certificatePath), - key: readFileSync(privateKeyPath) + cert: readFileSync(runtime.certificatePath), + key: readFileSync(runtime.privateKeyPath) } : undefined, cors: true, proxy: { diff --git a/test/backend/deployment_state_test.go b/test/backend/deployment_state_test.go new file mode 100644 index 0000000..9e936dc --- /dev/null +++ b/test/backend/deployment_state_test.go @@ -0,0 +1,173 @@ +package config + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/utils/credential" + "git.pcux.cn/xuans/Smail/utils/runtimepath" +) + +func TestDeploymentStateRecoversEveryCommitPhase(t *testing.T) { + for _, phase := range []TransactionPhase{TransactionPrepared, TransactionSectionsCommitted, TransactionRootCommitted, TransactionVerified} { + t.Run(string(phase), func(t *testing.T) { + root := t.TempDir() + if err := InitializeDeployment(root); err != nil { + t.Fatal(err) + } + current := validRuntimeConfig() + writeRuntimeFixture(t, root, current) + candidate := cloneRuntimeConfig(current) + setCandidateGeneration(&candidate, 2) + candidate.Email.Policy.MaxMessageBytes = 9 << 20 + validation := RuntimeValidationOptions{HasSuperAdmin: true, Production: true} + store := NewTransactionStore(root, validation).WithHooks(TransactionHooks{BeforePhase: func(actual TransactionPhase) error { + if actual == phase { + return errors.New("模拟进程中断") + } + return nil + }}) + if err := store.Commit(current, candidate); err == nil { + t.Fatal("未注入中断") + } + if err := NewTransactionStore(root, validation).Recover(); err != nil { + t.Fatal(err) + } + loaded, err := NewConfigLoader(root, ConfigLoaderOptions{Validation: validation}).Load() + if err != nil { + t.Fatal(err) + } + want := uint64(1) + if phase == TransactionRootCommitted || phase == TransactionVerified { + want = 2 + } + if loaded.Generation != want { + t.Fatalf("恢复代次错误:%d", loaded.Generation) + } + if err := store.Recover(); err != nil { + t.Fatal(err) + } + entries, _ := os.ReadDir(filepath.Join(root, "config")) + if len(entries) != 5 { + t.Fatal("config 生成了额外文件") + } + entries, _ = os.ReadDir(filepath.Join(root, "data")) + for _, entry := range entries { + if entry.Name() != "smail-state.db" { + t.Fatalf("data 中产生额外文件:%s", entry.Name()) + } + } + }) + } +} + +func TestDeploymentStateImportsHistoryWithoutChangingSecret(t *testing.T) { + source, target := t.TempDir(), t.TempDir() + current := validMySQLRuntimeConfig(t, source) + writeRuntimeFixture(t, source, current) + validation := RuntimeValidationOptions{HasSuperAdmin: true} + old := NewTransactionStore(source, validation) + backup, err := old.CreateBackup(current) + if err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(filepath.Join(backup, "files", "config.json")) + if err := InitializeDeployment(target); err != nil { + t.Fatal(err) + } + if _, err := runtimepath.SecureDirectory(target, "ssl"); err != nil { + t.Fatal(err) + } + key, err := os.ReadFile(filepath.Join(source, "data", "credential.key")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "ssl", "credential.key"), key, 0600); err != nil { + t.Fatal(err) + } + writeRuntimeFixture(t, target, current) + if err := ImportDeploymentHistory(source, target, validation); err != nil { + t.Fatal(err) + } + store := NewTransactionStore(target, validation) + loaded, err := store.LoadBackup(current.Generation) + if err != nil { + t.Fatal(err) + } + vault, err := credential.NewVault(target) + if err != nil { + t.Fatal(err) + } + if _, err := OpenStoredSecret(vault, loaded.Root.Database.Password); err != nil { + t.Fatal("迁移后密文无法解密") + } + items, err := store.stateBackups() + if err != nil || len(items) != 1 { + t.Fatalf("历史导入失败:%v", err) + } + if !bytes.Equal(before, items[0].Original["config.json"]) { + t.Fatal("历史原始内容被改写") + } + for index := 0; index < 6; index++ { + if _, err := store.createBackupAt(current, time.Now().Add(time.Duration(index)*time.Hour)); err != nil { + t.Fatal(err) + } + } + items, err = store.stateBackups() + if err != nil || len(items) != 4 { + t.Fatalf("历史保留策略错误:%v", err) + } +} + +func TestDeploymentStateRejectsTamperAndConcurrentWriter(t *testing.T) { + root := t.TempDir() + if err := InitializeDeployment(root); err != nil { + t.Fatal(err) + } + current := validRuntimeConfig() + writeRuntimeFixture(t, root, current) + store := NewTransactionStore(root, RuntimeValidationOptions{HasSuperAdmin: true}) + if _, err := store.CreateBackup(current); err != nil { + t.Fatal(err) + } + unlock, err := store.lockState() + if err != nil { + t.Fatal(err) + } + if another, err := store.lockState(); err == nil { + another() + t.Fatal("并行写入没有被拒绝") + } + unlock() + db, err := store.openState() + if err != nil { + t.Fatal(err) + } + _, err = db.Exec("UPDATE config_records SET payload=? WHERE kind='backup'", []byte("{}")) + db.Close() + if err != nil { + t.Fatal(err) + } + if _, err := store.LoadBackup(1); !errors.Is(err, ErrConfigDigestMismatch) { + t.Fatalf("篡改未被拒绝:%v", err) + } +} + +func TestDeploymentManagedReferenceUsesArgumentRoot(t *testing.T) { + root := t.TempDir() + if err := InitializeDeployment(root); err != nil { + t.Fatal(err) + } + private, certificate := testManagedCertificate(t, "example.com", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048) + ref, err := importManagedSSLAtRoot(root, ManagedSSLModeUpload, private, certificate, nil, []string{"example.com"}) + if err != nil { + t.Fatal(err) + } + if ref.CertificateRef != "ssl/managed/upload/current/public.crt" { + t.Fatalf("引用错误:%s", ref.CertificateRef) + } +} diff --git a/test/deployment/migrate_runtime.py b/test/deployment/migrate_runtime.py new file mode 100644 index 0000000..3a17957 --- /dev/null +++ b/test/deployment/migrate_runtime.py @@ -0,0 +1,123 @@ +"""停服后迁移隔离模拟部署,保留源文件和完整备份,不清空业务数据库。""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import sqlite3 +import subprocess +from datetime import datetime + + +ROOT = Path(__file__).resolve().parents[2] +TARGET = ROOT / "yunx-test" +CONFIG_NAMES = {f"{name}.json" for name in ("config", "smtp", "email", "ssl", "plugins")} + + +def safe_tree(directory): + for current, directories, files in os.walk(directory, followlinks=False): + for name in directories + files: + item = Path(current) / name + if item.is_symlink() or item.is_junction(): + raise RuntimeError("迁移集合包含链接,已停止") + if directory.is_symlink() or directory.is_junction(): + raise RuntimeError("迁移目录不能是链接") + + +def digest(filename): + return hashlib.file_digest(filename.open("rb"), "sha256").hexdigest() + + +def copy_verified(source, target): + if target.exists(): + if target.is_file() and digest(source) == digest(target): + return + raise RuntimeError(f"目标冲突,拒绝覆盖:{target.relative_to(ROOT)}") + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + if digest(source) != digest(target): + raise RuntimeError("文件复制摘要不一致") + + +def copy_tree(source, target): + safe_tree(source) + for item in source.rglob("*"): + if item.is_file(): + copy_verified(item, target / item.relative_to(source)) + + +def sqlite_snapshot(source, target): + with sqlite3.connect(source.as_uri() + "?mode=ro", uri=True) as src: + if src.execute("PRAGMA integrity_check").fetchone()[0] != "ok": + raise RuntimeError("源 SQLite 完整性检查未通过") + with sqlite3.connect(target) as dst: + src.backup(dst) + if dst.execute("PRAGMA integrity_check").fetchone()[0] != "ok": + raise RuntimeError("目标 SQLite 完整性检查未通过") + tables = [row[0] for row in src.execute("SELECT name FROM sqlite_master WHERE type='table'")] + for name in tables: + quoted = '"' + name.replace('"', '""') + '"' + if src.execute(f"SELECT count(*) FROM {quoted}").fetchone() != dst.execute(f"SELECT count(*) FROM {quoted}").fetchone(): + raise RuntimeError("SQLite 表记录数量不一致") + return len(tables) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + safe_tree(TARGET) + for name in ("config", "data", "ssl"): + safe_tree(ROOT / name) + if {item.name for item in (ROOT / "config").iterdir()} != CONFIG_NAMES: + raise RuntimeError("源配置目录不止五个指定配置") + if any((ROOT / "data" / "transaction").iterdir()): + raise RuntimeError("源目录存在未恢复配置事务,不能迁移") + if any((TARGET / name).exists() for name in ("config", "ssl", "data")): + raise RuntimeError("目标已有运行数据,拒绝重复迁移") + if not args.apply: + print("迁移集合检查通过。先停止前后端,再使用 --apply。") + return + # 再次核对端口,避免复制期间仍有配置写入。 + result = subprocess.run([ + "powershell.exe", "-NoProfile", "-Command", + "if (Get-NetTCPConnection -State Listen -LocalPort 5201 -ErrorAction SilentlyContinue) { exit 1 }" + ], capture_output=True) + if result.returncode: + raise RuntimeError("5201 后端仍在运行,拒绝迁移") + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup = ROOT / "test-log" / f"deployment-migration-{stamp}" + backup.mkdir(parents=True, exist_ok=False) + for name in ("config", "data", "ssl"): + copy_tree(ROOT / name, backup / name) + for name in ("config", "ssl", "data", "log"): + (TARGET / name).mkdir(parents=True, exist_ok=True) + copy_tree(backup / "config", TARGET / "config") + copy_tree(backup / "data" / "ssl", TARGET / "ssl") + copy_tree(backup / "ssl", TARGET / "ssl") + copy_tree(backup / "data" / "dkim", TARGET / "ssl" / "dkim") + for name in ("credential.key", "external-email-encryption.key", "external-email-fingerprint.key"): + copy_verified(backup / "data" / name, TARGET / "ssl" / name) + copy_verified(backup / "data" / "config-audit-v2.jsonl", TARGET / "log" / "config-audit-v2.jsonl") + databases = {} + for source in (ROOT / "data").glob("*.db"): + databases[source.name] = {"tables": sqlite_snapshot(source, TARGET / "data" / source.name), "integrity": "ok"} + env = {**os.environ, "SMAIL_ROOT": str(TARGET)} + result = subprocess.run([ + str(ROOT / "out" / "backend" / "smail-config.exe"), "--root", str(TARGET), + "--deployment-history", str(ROOT), "--apply" + ], env=env, capture_output=True) + (backup / "import.stdout.log").write_bytes(result.stdout) + (backup / "import.stderr.log").write_bytes(result.stderr) + if result.returncode: + raise RuntimeError("配置历史导入失败;保留原目录、目标和备份供排查,不启动服务") + report = {"source": str(ROOT), "target": str(TARGET), "backup": str(backup), "databases": databases, + "files": {str(item.relative_to(TARGET)): digest(item) for item in TARGET.rglob("*") if item.is_file()}} + (backup / "receipt.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"迁移完成;一致性验收记录:{backup.relative_to(ROOT)}/receipt.json") + + +if __name__ == "__main__": + main() diff --git a/test/frontend/output-paths.test.mjs b/test/frontend/output-paths.test.mjs index 2bc9592..8a76e43 100644 --- a/test/frontend/output-paths.test.mjs +++ b/test/frontend/output-paths.test.mjs @@ -42,15 +42,15 @@ test('前端入口按需加载 Element Plus,并延迟设置页和写信编辑 test('开发启动必须固定运行配置根目录并集中保存日志', async () => { const content = await read('deploy/start-dev.ps1') - assert.match(content, /\$env:SMAIL_ROOT = \$root\r?\n/) + assert.match(content, /\$env:SMAIL_ROOT = \$runtime\r?\n/) assert.match(content, /\$legacyConfig = Join-Path \$root 'backend\\config'/) assert.match(content, /Test-Path -LiteralPath \(Join-Path \$legacyConfig \$name\)/) - assert.match(content, /\$workingDirectory = Join-Path \$root 'out'/) + assert.match(content, /\$workingDirectory = \$runtime/) for (const name of ['spam_block.exe', 'pmail_telegram_push.exe', 'wechat_push.exe']) assert.ok(content.includes(name)) - assert.match(content, /out\\backend\\smail-dev\.exe/) - assert.match(content, /test-log\\runtime/) + assert.match(content, /Join-Path \$runtime 'smail\.exe'/) + assert.match(content, /Join-Path \$runtime 'log'/) assert.match(content, /-WindowStyle Hidden/) - assert.match(content, /\[switch\]\$SkipBuild/) + assert.doesNotMatch(content, /SkipBuild/) assert.match(content, /--dev-disable-admin-reset/) assert.match(content, /SMAIL_HTTP_PORT_OVERRIDE = '5201'/) assert.match(content, /SMAIL_HTTP_TLS_MODE_OVERRIDE = 'proxy'/) @@ -59,7 +59,7 @@ test('开发启动必须固定运行配置根目录并集中保存日志', async assert.match(await read('frontend/.env.development'), /VITE_DEV_BACKEND_URL=http:\/\/127\.0\.0\.1:5201/) assert.match(await read('frontend/vite.config.js'), /port: 443/) assert.match(await read('frontend/vite.config.js'), /smail-development-http-redirect/) - assert.match(content, /默认先编译当前工作区/) + assert.match(content, /deploy-runtime\.ps1/) assert.ok(content.charCodeAt(0) === 0xfeff, 'Windows PowerShell 5 脚本应有 UTF-8 BOM') }) @@ -74,8 +74,8 @@ test('六个平台默认包含三个插件,构建和发布名称与后端目 assert.ok(catalog.includes(`"${path.basename(item.output)}"`)) } const files = packagePlan(target) - assert.equal(files.length, 6) - assert.equal(files.filter(file => path.basename(path.dirname(file.target)) === 'plugins').length, 3) + assert.equal(files.length, 2) + assert.equal(files.filter(file => path.basename(path.dirname(file.target)) === 'plugins').length, 0) assert.ok(files.every(file => file.target.startsWith(path.join(root, 'out', 'package', target)))) } assert.ok(buildPlan({ development: true }).every(item => path.dirname(item.output) === path.join(root, 'out', 'plugins'))) diff --git a/test/frontend/runtime-deployment.test.mjs b/test/frontend/runtime-deployment.test.mjs new file mode 100644 index 0000000..99b94df --- /dev/null +++ b/test/frontend/runtime-deployment.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, readFile, 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 { fileDigest, sourceFingerprint } from '../../deploy/build-state.mjs' + +test('开发配置只从模拟部署目录读取且不返回凭据字段', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'smail-runtime-config-')) + await mkdir(path.join(root, 'config')) + await mkdir(path.join(root, 'ssl')) + await writeFile(path.join(root, 'config/config.json'), JSON.stringify({ + generation: 9, domains: { web: 'mail.example.test' }, database: { password: 'not-exported' } + })) + 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') + const runtime = readDevelopmentRuntime(root) + assert.equal(runtime.origin, 'https://mail.example.test') + assert.equal(runtime.generation, 9) + assert.equal(runtime.privateKeyPath, path.join(root, 'ssl/private.key')) + assert.equal(JSON.stringify(runtime).includes('not-exported'), false) + assert.equal(runtimeRoot, path.join(sourceRoot, 'yunx-test')) + assert.throws(() => resolveRuntimeFile(root, '../outside.key')) + assert.throws(() => resolveRuntimeFile(root, path.join(root, 'ssl/private.key'))) +}) + +test('源码指纹检测编辑且忽略编译嵌入目录', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'smail-build-state-')) + for (const name of ['backend', 'frontend', 'plugins/pmail_telegram_push', 'deploy']) { + await mkdir(path.join(root, name), { recursive: true }) + } + await writeFile(path.join(root, 'backend/main.go'), 'package main') + const before = await sourceFingerprint(root) + await mkdir(path.join(root, 'backend/dist')) + await writeFile(path.join(root, 'backend/dist/index.html'), 'generated') + assert.equal(await sourceFingerprint(root), before) + await writeFile(path.join(root, 'backend/main.go'), 'package main\n') + assert.notEqual(await sourceFingerprint(root), before) + assert.equal((await fileDigest(path.join(root, 'backend/main.go'))).length, 64) + await writeFile(path.join(root, 'empty.exe'), '') + await assert.rejects(fileDigest(path.join(root, 'empty.exe'))) +}) + +test('部署和启动必须完整构建、验证产物且不覆盖运行配置', async () => { + const read = name => readFile(path.join(sourceRoot, name), 'utf8') + const deploy = await read('deploy/deploy-runtime.ps1') + const start = await read('deploy/start-dev.ps1') + assert.ok(deploy.indexOf("'build.ps1'") < deploy.indexOf('Copy-Item')) + assert.match(deploy, /verify --deployed/) + assert.match(deploy, /deployment-history/) + assert.doesNotMatch(deploy, /Copy-Item[^\n]*(config|data|ssl)/) + assert.doesNotMatch(start, /SkipBuild|SkipFrontend/) + assert.match(start, /\$env:SMAIL_ROOT = \$runtime/) + assert.match(start, /\$workingDirectory = \$runtime/) + assert.match(start, /build-state\.mjs'\) verify --deployed/) + assert.match(await read('.gitignore'), /^\/yunx-test\/$/m) +}) diff --git a/test/run.mjs b/test/run.mjs index d235389..3230ef9 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -42,6 +42,7 @@ if (suite !== 'frontend' && result === 0) { const args = extra[0] === '--' ? extra.slice(1) : extra // 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。 const replacements = { + 'backend/config/deployment_state_test.go': 'test/backend/deployment_state_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',