保存模拟部署迁移与最新构建校验阶段成果
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-08 18:33:21 +08:00
parent 519e2b4a5f
commit d7837de561
45 changed files with 1588 additions and 109 deletions

View File

@ -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 <<EOF
{
"version": "${{ env.VERSION }}",
"gitHash": "$(git show -s --format=%H)",
"protocolVersion": 1,
"createdAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
cp ../../README.md README.md
ls -alh
node deploy/bundle-plugins.mjs ${{ matrix.goos }}_${{ matrix.goarch }}
extension=""
if [ "${{ matrix.goos }}" = "windows" ]; then extension=".exe"; fi
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 -tags bundled -ldflags "$BUILD_FLAGS" -o ../out/release/smail$extension ./cmd/smail
go build -ldflags "$BUILD_FLAGS" -o ../out/release/smail-admin$extension ./cmd/smail-admin
- name: Zip
run: |
cd ./out/release
zip -r ${{ env.ZIPNAME }}.zip ${{ env.FILENAME }} ${{ env.ADMINFILENAME }} plugins release.json README.md
zip -r ${{ env.ZIPNAME }}.zip smail* config ssl data log
ls
- name: Upload files to Artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02

2
.gitignore vendored
View File

@ -10,6 +10,7 @@
/config/
/data/
/ssl/
/yunx-test/
# 后端配置目录混放源码与运行数据:只放行代码、文档和脱敏示例
/backend/config/*
@ -37,6 +38,7 @@
# Go 嵌入中间文件,以及旧命令仍可能生成的构建目录
/backend/listen/http_server/dist/
/backend/cmd/smail/bundled/
/frontend/dist/
/frontend/dist-ssr/
/plugins/pmail_telegram_push/hook/dist/

View File

@ -17,14 +17,20 @@ clean_codex_test_artifacts:
build_fe:
cd frontend && yarn && yarn build
build_server: build_fe
build_server: build_fe plugin
mkdir -p out/backend
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_linux_amd64 ./cmd/smail
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_linux_arm64 ./cmd/smail
cd backend && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_windows_amd64.exe ./cmd/smail
cd backend && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_windows_arm64.exe ./cmd/smail
cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_mac_amd64 ./cmd/smail
cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_mac_arm64 ./cmd/smail
node deploy/bundle-plugins.mjs linux_amd64
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags bundled -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_linux_amd64 ./cmd/smail
node deploy/bundle-plugins.mjs linux_arm64
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -tags bundled -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_linux_arm64 ./cmd/smail
node deploy/bundle-plugins.mjs windows_amd64
cd backend && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -tags bundled -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_windows_amd64.exe ./cmd/smail
node deploy/bundle-plugins.mjs windows_arm64
cd backend && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -tags bundled -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_windows_arm64.exe ./cmd/smail
node deploy/bundle-plugins.mjs darwin_amd64
cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -tags bundled -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_mac_amd64 ./cmd/smail
node deploy/bundle-plugins.mjs darwin_arm64
cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -tags bundled -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_mac_arm64 ./cmd/smail
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-admin_linux_amd64 ./cmd/smail-admin
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-admin_linux_arm64 ./cmd/smail-admin
cd backend && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-admin_windows_amd64.exe ./cmd/smail-admin

View File

@ -23,6 +23,7 @@ func run(args []string) error {
root := flags.String("root", "", "Smail 服务根目录,包含 config 子目录")
apply := flags.Bool("apply", false, "确认已停止后端,备份并执行迁移;默认只检查")
runtimeData := flags.Bool("runtime-data", false, "将运行数据迁到 data,保留当前五个配置文件")
historySource := flags.String("deployment-history", "", "停服复制完成后,从原部署导入配置历史并转换证书引用")
if err := flags.Parse(args); err != nil {
return err
}
@ -34,6 +35,19 @@ func run(args []string) error {
return fmt.Errorf("服务根目录无效")
}
options := config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true}
if *historySource != "" {
if !*apply || *runtimeData {
return fmt.Errorf("部署历史导入必须单独使用 --apply,且先停止源与目标后端")
}
if err := config.ImportDeploymentHistory(*historySource, absolute, options); err != nil {
return fmt.Errorf("配置历史导入失败:%w", err)
}
if err := config.NormalizeDeploymentReferences(absolute, options); err != nil {
return fmt.Errorf("部署引用转换失败:%w", err)
}
fmt.Println("配置历史已导入状态数据库,生效配置仍为 config 下的五个文件。")
return nil
}
if *runtimeData {
moves, err := runtimepath.Plan(absolute)
if err != nil {

View File

@ -0,0 +1,21 @@
//go:build bundled
package main
import (
"embed"
"io/fs"
"git.pcux.cn/xuans/Smail/hooks"
)
//go:embed bundled/*
var pluginFiles embed.FS
func init() {
files, err := fs.Sub(pluginFiles, "bundled")
if err != nil {
panic(err)
}
hooks.SetBundledPlugins(files)
}

View File

@ -46,7 +46,11 @@ func NewFileAuditWriter(root string) (*FileAuditWriter, error) {
if strings.TrimSpace(root) == "" {
return nil, ErrAuditStorageUnavailable
}
directory, err := runtimepath.Directory(root)
name := "data"
if runtimepath.DeploymentLayout(root) {
name = "log"
}
directory, err := runtimepath.SecureDirectory(root, name)
if err != nil {
return nil, err
}

View File

@ -5,6 +5,8 @@ import (
"path/filepath"
"sort"
"strings"
"git.pcux.cn/xuans/Smail/utils/runtimepath"
)
// ListBackups 枚举合法备份的代次和时间,不返回内部路径。
@ -12,6 +14,17 @@ func (s *TransactionStore) ListBackups() ([]ConfigBackupInfo, error) {
if s == nil || s.root == "" {
return nil, ErrBackupListingUnavailable
}
if runtimepath.DeploymentLayout(s.root) {
items, err := s.stateBackups()
if err != nil {
return nil, err
}
result := make([]ConfigBackupInfo, 0, len(items))
for _, item := range items {
result = append(result, ConfigBackupInfo{Generation: item.Backup.Generation, CreatedAt: item.Backup.CreatedAt})
}
return result, nil
}
configDirectory, err := secureManagerConfigDirectory(s.root)
if err != nil {
return nil, err

View File

@ -276,7 +276,7 @@ func legacyInitReady() bool {
}
func ReadPrivateKey() (*ecdsa.PrivateKey, bool, error) {
privateKey, created, err := loadOrCreateACMEPrivateKey(filepath.Join(ROOT_PATH, "data", "ssl"), "account_private.pem")
privateKey, created, err := loadOrCreateACMEPrivateKey(runtimepath.SSLDirectory(ROOT_PATH), "account_private.pem")
if err != nil {
return nil, false, err
}
@ -332,9 +332,9 @@ func ReadConfig() (*Config, error) {
func readConfig() (*Config, error) {
configData := Config{
DkimPrivateKeyPath: ROOT_PATH + "data/dkim/dkim.priv",
SSLPrivateKeyPath: ROOT_PATH + "data/ssl/private.key",
SSLPublicKeyPath: ROOT_PATH + "data/ssl/public.crt",
DkimPrivateKeyPath: filepath.Join(runtimepath.DKIMDirectory(ROOT_PATH), "dkim.priv"),
SSLPrivateKeyPath: filepath.Join(runtimepath.SSLDirectory(ROOT_PATH), "private.key"),
SSLPublicKeyPath: filepath.Join(runtimepath.SSLDirectory(ROOT_PATH), "public.crt"),
VerificationEnabled: true,
ExternalRecoveryEnabled: true,
LoginPolicy: "new_device",
@ -446,7 +446,7 @@ func persistControlledSSLPath(value string) string {
}
func resolvePath(value string) string {
value = runtimepath.LegacyReference(strings.TrimSpace(value))
value = runtimepath.SSLReference(ROOT_PATH, strings.TrimSpace(value))
if value == "" {
return ""
}

View File

@ -0,0 +1,96 @@
package config
import (
"errors"
"os"
"path/filepath"
fileutil "git.pcux.cn/xuans/Smail/utils/file"
"git.pcux.cn/xuans/Smail/utils/runtimepath"
)
// InitializeDeployment 仅对全新安装启用新布局,不触碰已有部署或生成替代密钥。
func InitializeDeployment(root string) error {
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
} else if !os.IsNotExist(err) {
return err
}
}
db, err := NewTransactionStore(root, RuntimeValidationOptions{}).openState()
if err != nil {
return err
}
return db.Close()
}
// ImportDeploymentHistory 在停服迁移期间导入旧历史原始字节,原目录保持不动。
func ImportDeploymentHistory(source, target string, validation RuntimeValidationOptions) error {
if !runtimepath.DeploymentLayout(target) {
return errors.New("目标部署密钥尚未迁移到 ssl")
}
if entries, err := os.ReadDir(filepath.Join(source, "data", "transaction")); err == nil && len(entries) != 0 {
return ErrConfigRecoveryRequired
} else if err != nil && !os.IsNotExist(err) {
return err
}
store := NewTransactionStore(target, validation)
unlock, err := store.lockState()
if err != nil {
return err
}
defer unlock()
entries, err := os.ReadDir(filepath.Join(source, "data", "backups-v2"))
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
for _, entry := range entries {
directory := filepath.Join(source, "data", "backups-v2", entry.Name())
_, manifest, err := loadConfigBackup(directory, validation)
if err != nil {
return err
}
files := make(map[string][]byte)
for _, name := range transactionFileNames() {
files[name], err = fileutil.ReadRegularFile(filepath.Join(directory, "files", name), maxConfigFileSize)
if err != nil {
return err
}
}
if err := validateStateFiles(files, manifest.Files); err != nil {
return err
}
if err := store.saveState("backup", manifest.ID, stateRecord{Backup: &manifest, Original: files}); err != nil {
return err
}
}
_, err = store.stateBackups()
return err
}
// NormalizeDeploymentReferences 通过正常事务更新路径及摘要,不能直接编辑配置 JSON。
func NormalizeDeploymentReferences(root string, validation RuntimeValidationOptions) error {
loader := NewConfigLoader(root, ConfigLoaderOptions{Validation: validation})
current, err := loader.Load()
if err != nil {
return err
}
if !runtimepath.DeploymentLayout(root) {
return ErrManagerStorage
}
candidate := cloneRuntimeConfig(current)
ssl := &candidate.SSL.TLS
ssl.DefaultDirectory = runtimepath.SSLReference(root, ssl.DefaultDirectory)
ssl.CustomDirectory = runtimepath.SSLReference(root, ssl.CustomDirectory)
ssl.CertificateRef = runtimepath.SSLReference(root, ssl.CertificateRef)
ssl.PrivateKeyRef = runtimepath.SSLReference(root, ssl.PrivateKeyRef)
if candidate.SSL.TLS.CertificateRef == current.SSL.TLS.CertificateRef && candidate.SSL.TLS.PrivateKeyRef == current.SSL.TLS.PrivateKeyRef && candidate.SSL.TLS.DefaultDirectory == current.SSL.TLS.DefaultDirectory && candidate.SSL.TLS.CustomDirectory == current.SSL.TLS.CustomDirectory {
return nil
}
setCandidateGeneration(&candidate, current.Generation+1)
return NewTransactionStore(root, validation).Commit(current, candidate)
}

View File

@ -10,6 +10,7 @@ import (
"strings"
"git.pcux.cn/xuans/Smail/utils/credential"
"git.pcux.cn/xuans/Smail/utils/runtimepath"
"github.com/go-sql-driver/mysql"
)
@ -58,7 +59,7 @@ func runtimeCompatibility(runtime RuntimeConfig) (*Config, error) {
HttpPort: root.HTTP.Port, HttpsPort: root.HTTP.HTTPSPort,
HttpsEnabled: 2, TLSMode: root.HTTP.TLSMode,
TrustedProxyCIDRs: append([]string(nil), root.HTTP.TrustedProxyCIDRs...),
DkimPrivateKeyPath: filepath.Join(ROOT_PATH, "data", "dkim", "dkim.priv"),
DkimPrivateKeyPath: filepath.Join(runtimepath.DKIMDirectory(ROOT_PATH), "dkim.priv"),
SSLType: SSLTypeUser,
SpamFilterLevel: runtime.Email.Policy.SpamFilterLevel,
LoginPolicy: runtime.SMTP.Relay.LoginPolicy,
@ -86,7 +87,7 @@ func runtimeCompatibility(runtime RuntimeConfig) (*Config, error) {
}
}
// 迁移器保留的旧证书引用仍走严格的只读文件校验;新托管引用沿用托管解析器。
if strings.HasPrefix(tls.CertificateRef, managedSSLRelativeRoot+"/") {
if strings.HasPrefix(tls.CertificateRef, managedSSLRelativeRoot+"/") || strings.HasPrefix(tls.CertificateRef, "ssl/managed/") {
cfg.SSL = tls
} else {
cfg.SSLPublicKeyPath = resolvePath(tls.CertificateRef)

View File

@ -6,6 +6,8 @@ import (
"os"
"path/filepath"
"strings"
"git.pcux.cn/xuans/Smail/utils/runtimepath"
)
var ErrSSLFileUnavailable = errors.New("SSL certificate file is missing or unreadable")
@ -32,7 +34,8 @@ func prepareCustomSSLPaths(root string, candidate *RuntimeConfig) (*customSSLMat
return nil, ErrInvalidSSLReference
}
// 已保存的受管引用只复验,不重复导入;普通相对路径以项目运行根目录为基准。
if strings.HasPrefix(certificatePath, managedSSLRelativeRoot+"/") && strings.HasPrefix(privateKeyPath, managedSSLRelativeRoot+"/") {
managedRoot := runtimepath.SSLReference(root, managedSSLRelativeRoot)
if strings.HasPrefix(certificatePath, managedRoot+"/") && strings.HasPrefix(privateKeyPath, managedRoot+"/") {
return nil, validateCustomSSLReferences(root, *candidate)
}
var err error
@ -57,11 +60,11 @@ func prepareCustomSSLPaths(root string, candidate *RuntimeConfig) (*customSSLMat
return nil, err
}
ssl.Mode = ManagedSSLModeUpload
ssl.DefaultDirectory = "data/ssl"
ssl.CustomDirectory = "data/ssl"
ssl.DefaultDirectory = runtimepath.SSLReference(root, "data/ssl")
ssl.CustomDirectory = runtimepath.SSLReference(root, "data/ssl")
// 仅在内存中使用合法占位引用完成跨分区校验,最终候选必须换成独立版本。
ssl.CertificateRef = managedSSLReference(ManagedSSLModeUpload, "public.crt")
ssl.PrivateKeyRef = managedSSLReference(ManagedSSLModeUpload, "private.key")
ssl.CertificateRef = managedSSLReferenceAtRoot(root, ManagedSSLModeUpload, "public.crt")
ssl.PrivateKeyRef = managedSSLReferenceAtRoot(root, ManagedSSLModeUpload, "private.key")
return material, nil
}

View File

@ -195,8 +195,8 @@ func importManagedSSLAtRoot(root string, mode string, privateKeyPEM, certificate
}
return ManagedSSLReference{
Mode: mode,
CertificateRef: managedSSLReference(mode, "public.crt"),
PrivateKeyRef: managedSSLReference(mode, "private.key"),
CertificateRef: managedSSLReferenceAtRoot(root, mode, "public.crt"),
PrivateKeyRef: managedSSLReferenceAtRoot(root, mode, "private.key"),
CertificatePath: certificatePath,
PrivateKeyPath: privateKeyPath,
}, nil
@ -388,15 +388,19 @@ func managedSSLRootPathAtRoot(root string, mode string) (string, error) {
if mode == ManagedSSLModeUpload {
folder = "upload"
}
return filepath.Join(rootPath, managedSSLRelativeRoot, folder), nil
return filepath.Join(rootPath, runtimepath.SSLReference(root, managedSSLRelativeRoot), folder), nil
}
func managedSSLReference(mode, name string) string {
return managedSSLReferenceAtRoot(ROOT_PATH, mode, name)
}
func managedSSLReferenceAtRoot(root, mode, name string) string {
folder := "default"
if mode == ManagedSSLModeUpload {
folder = "upload"
}
return filepath.ToSlash(filepath.Join(managedSSLRelativeRoot, folder, "current", name))
return filepath.ToSlash(filepath.Join(runtimepath.SSLReference(root, managedSSLRelativeRoot), folder, "current", name))
}
func resolveManagedReference(rootPath, reference, expectedName string) (string, error) {
@ -404,7 +408,7 @@ func resolveManagedReference(rootPath, reference, expectedName string) (string,
}
func resolveManagedReferenceAtRoot(root, rootPath, reference, expectedName string) (string, error) {
rawReference := runtimepath.LegacyReference(strings.TrimSpace(reference))
rawReference := runtimepath.SSLReference(root, strings.TrimSpace(reference))
if rawReference == "" || strings.Contains(rawReference, `\`) || filepath.IsAbs(rawReference) || isWindowsAbsolute(rawReference) {
return "", ErrInvalidSSLReference
}

View File

@ -0,0 +1,14 @@
//go:build !windows
package config
import (
"os"
"golang.org/x/sys/unix"
)
func lockStateFile(file *os.File) (func(), error) {
err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
return func() { _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) }, err
}

View File

@ -0,0 +1,15 @@
//go:build windows
package config
import (
"os"
"golang.org/x/sys/windows"
)
func lockStateFile(file *os.File) (func(), error) {
position := &windows.Overlapped{Offset: 0x60000000}
err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, position)
return func() { _ = windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, position) }, err
}

View File

@ -0,0 +1,379 @@
package config
import (
"database/sql"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"time"
"git.pcux.cn/xuans/Smail/utils/atomicfile"
"git.pcux.cn/xuans/Smail/utils/runtimepath"
_ "modernc.org/sqlite"
)
// 状态数据库只保存配置历史和恢复事务,五个生效配置文件仍是配置读取来源。
type stateRecord struct {
Transaction *TransactionManifest `json:"transaction,omitempty"`
Backup *ConfigBackupManifest `json:"backup,omitempty"`
Original map[string][]byte `json:"original,omitempty"`
Candidate map[string][]byte `json:"candidate,omitempty"`
}
func (s *TransactionStore) lockState() (func(), error) {
db, err := s.openState()
if err != nil {
return nil, err
}
if err := db.Close(); err != nil {
return nil, err
}
file, err := os.OpenFile(filepath.Join(s.root, "data", "smail-state.db"), os.O_RDWR, 0600)
if err != nil {
return nil, err
}
unlock, err := lockStateFile(file)
if err != nil {
file.Close()
return nil, errors.New("配置状态正在被其他进程使用,请稍后重试")
}
return func() { unlock(); file.Close() }, nil
}
func (s *TransactionStore) openState() (*sql.DB, error) {
directory, err := runtimepath.Directory(s.root)
if err != nil {
return nil, err
}
filename := filepath.Join(directory, "smail-state.db")
info, err := os.Lstat(filename)
if err == nil && (!info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0) {
return nil, ErrManagerStorage
}
if err != nil && !os.IsNotExist(err) {
return nil, err
}
db, err := sql.Open("sqlite", filepath.ToSlash(filename))
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
for _, statement := range []string{
"PRAGMA busy_timeout=5000", "PRAGMA synchronous=FULL",
`CREATE TABLE IF NOT EXISTS config_records (kind TEXT NOT NULL, id TEXT NOT NULL, payload BLOB NOT NULL, digest TEXT NOT NULL, PRIMARY KEY(kind,id))`,
} {
if _, err = db.Exec(statement); err != nil {
db.Close()
return nil, err
}
}
if err = os.Chmod(filename, 0600); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func (s *TransactionStore) saveState(kind, id string, record stateRecord) error {
if !transactionIDPattern.MatchString(id) {
return ErrTransactionInvalid
}
data, err := json.Marshal(record)
if err != nil {
return err
}
db, err := s.openState()
if err != nil {
return err
}
defer db.Close()
_, err = db.Exec(`INSERT INTO config_records(kind,id,payload,digest) VALUES(?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,digest=excluded.digest`, kind, id, data, SectionDigest(data))
return err
}
func validateStateFiles(files map[string][]byte, digests map[string]string) error {
if !validFileDigestSet(digests) || len(files) != len(digests) {
return ErrTransactionInvalid
}
for name, digest := range digests {
if len(files[name]) == 0 || len(files[name]) > 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
}

View File

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

View File

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

View File

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

52
backend/hooks/bundle.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

91
deploy/build-state.mjs Normal file
View File

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

View File

@ -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 的跨平台构建目标。'

23
deploy/bundle-plugins.mjs Normal file
View File

@ -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('三个插件已准备嵌入主程序。')

9
deploy/check-runtime.mjs Normal file
View File

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

46
deploy/deploy-runtime.ps1 Normal file
View File

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

View File

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

68
deploy/runtime-config.mjs Normal file
View File

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

View File

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

View File

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

View File

@ -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 和管理员恢复通道均已验收。迁移完成前,不能使用旧布局的测试结果证明新布局正确。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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