This commit is contained in:
parent
519e2b4a5f
commit
d7837de561
33
.github/workflows/release.yml
vendored
33
.github/workflows/release.yml
vendored
@ -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
2
.gitignore
vendored
@ -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/
|
||||
|
||||
20
Makefile
20
Makefile
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
21
backend/cmd/smail/bundle.go
Normal file
21
backend/cmd/smail/bundle.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
96
backend/config/deployment_state.go
Normal file
96
backend/config/deployment_state.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
14
backend/config/state_lock_unix.go
Normal file
14
backend/config/state_lock_unix.go
Normal 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
|
||||
}
|
||||
15
backend/config/state_lock_windows.go
Normal file
15
backend/config/state_lock_windows.go
Normal 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
|
||||
}
|
||||
379
backend/config/state_store.go
Normal file
379
backend/config/state_store.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
52
backend/hooks/bundle.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
84
backend/utils/runtimepath/deployment.go
Normal file
84
backend/utils/runtimepath/deployment.go
Normal 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")))
|
||||
}
|
||||
@ -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
91
deploy/build-state.mjs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
23
deploy/bundle-plugins.mjs
Normal 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
9
deploy/check-runtime.mjs
Normal 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
46
deploy/deploy-runtime.ps1
Normal 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() }
|
||||
}
|
||||
@ -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
68
deploy/runtime-config.mjs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
35
deploy/start-production.ps1
Normal file
35
deploy/start-production.ps1
Normal 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') }
|
||||
}
|
||||
45
docs/模拟部署与最新构建.md
Normal file
45
docs/模拟部署与最新构建.md
Normal 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 和管理员恢复通道均已验收。迁移完成前,不能使用旧布局的测试结果证明新布局正确。
|
||||
@ -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
|
||||
|
||||
@ -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: {
|
||||
|
||||
173
test/backend/deployment_state_test.go
Normal file
173
test/backend/deployment_state_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
123
test/deployment/migrate_runtime.py
Normal file
123
test/deployment/migrate_runtime.py
Normal 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()
|
||||
@ -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')))
|
||||
|
||||
61
test/frontend/runtime-deployment.test.mjs
Normal file
61
test/frontend/runtime-deployment.test.mjs
Normal 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)
|
||||
})
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user