Smail/backend/utils/runtimepath/layout.go
smail a045a0cd17
Some checks are pending
Security Checks / security (push) Waiting to run
实现部署启动自动归档密钥与证书双版本保留
2026-09-08 20:34:38 +08:00

273 lines
7.9 KiB
Go

// Package runtimepath 统一运行数据目录;配置文件仍固定保存在 config 中。
package runtimepath
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
var ErrMigrationRequired = errors.New("运行数据仍在 config 中,请停止服务并运行 smail-config --runtime-data --apply")
const migrationLock = ".runtime-migration.lock"
// Directory 创建受保护的数据目录,拒绝符号链接及普通文件。
func Directory(root string) (string, error) {
if strings.TrimSpace(root) == "" {
return "", errors.New("运行根目录为空")
}
directory := filepath.Join(root, "data")
if err := os.Mkdir(directory, 0700); err != nil && !os.IsExist(err) {
return "", err
}
info, err := os.Lstat(directory)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return "", errors.New("运行数据目录无效")
}
if err := os.Chmod(directory, 0700); err != nil {
return "", err
}
return directory, nil
}
// LegacyReference 只转换旧运行目录的相对引用,不放宽路径安全检查。
func LegacyReference(value string) string {
valueWithoutDot := strings.TrimPrefix(value, "./")
for _, prefix := range []string{"config/ssl", "config/dkim"} {
if valueWithoutDot == prefix || strings.HasPrefix(valueWithoutDot, prefix+"/") {
return "data/" + strings.TrimPrefix(valueWithoutDot, "config/")
}
}
return value
}
var configurationFiles = map[string]bool{
"config.json": true, "smtp.json": true, "email.json": true,
"ssl.json": true, "plugins.json": true,
}
var runtimeEntries = map[string]bool{
"credential.key": true, "external-email-encryption.key": true,
"external-email-fingerprint.key": true, "config-audit-v2.jsonl": true,
"ssl": true, "dkim": true, "backups": true, "backups-v2": true,
"migration-backups": true, "transaction": true,
}
var obsoleteFiles = map[string]bool{
"config.dev.json": true, "config_mysql.json": true, "config.json.legacy": true,
}
type Move struct {
Source string
Target string
}
// Plan 检查整个迁移集合,未知文件、目标冲突或链接均拒绝自动处理。
func Plan(root string) ([]Move, error) {
absolute, err := filepath.Abs(root)
if err != nil {
return nil, err
}
configDirectory := filepath.Join(absolute, "config")
if err := safeTree(configDirectory); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
dataDirectory := filepath.Join(absolute, "data")
// 只检查目标父目录,不在每次启动时遍历全部邮件备份和证书历史。
if err := safeDirectory(dataDirectory); err != nil && !os.IsNotExist(err) {
return nil, err
}
entries, err := os.ReadDir(configDirectory)
if err != nil {
return nil, err
}
var moves []Move
for _, entry := range entries {
name := entry.Name()
if configurationFiles[name] && !entry.IsDir() {
continue
}
target := filepath.Join(absolute, "data", name)
switch {
case obsoleteFiles[name] && !entry.IsDir():
target = filepath.Join(absolute, "data", "legacy-config", name)
case runtimeEntries[name], !entry.IsDir() && databaseFile(name):
default:
return nil, fmt.Errorf("config 中存在未识别项目,请人工核对:%s", name)
}
if err := safeDirectory(filepath.Dir(target)); err != nil && !os.IsNotExist(err) {
return nil, err
}
if _, err := os.Lstat(target); err == nil {
return nil, fmt.Errorf("迁移目标已存在,拒绝覆盖:%s", name)
} else if !os.IsNotExist(err) {
return nil, err
}
moves = append(moves, Move{Source: filepath.Join(configDirectory, name), Target: target})
}
return moves, nil
}
func databaseFile(name string) bool {
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
name = strings.TrimSuffix(name, suffix)
}
switch filepath.Ext(name) {
case ".db", ".sqlite", ".sqlite3":
return true
}
return false
}
func safeTree(path string) error {
return filepath.WalkDir(path, func(_ string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.Type()&os.ModeSymlink != 0 || (!entry.IsDir() && !entry.Type().IsRegular()) {
return errors.New("迁移目录中存在链接或特殊文件")
}
return nil
})
}
func safeDirectory(path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("运行目录必须是实际目录,不能是链接或普通文件")
}
return nil
}
// 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("运行目录迁移尚未结束,请保留现场并核对迁移锁")
}
moves, err := Plan(root)
if err != nil {
return err
}
if len(moves) != 0 {
return ErrMigrationRequired
}
// 已初始化部署缺失账号库或加密密钥时必须报错,不能按首次安装处理。
raw, err := os.ReadFile(filepath.Join(root, "config", "config.json"))
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var config struct {
SetupState string `json:"setupState"`
Database struct {
Type string `json:"type"`
LocalRef string `json:"localRef"`
} `json:"database"`
}
if err := json.Unmarshal(raw, &config); err != nil {
return err
}
if config.SetupState != "ready" {
return nil
}
required := BusinessKeyNames()
if config.Database.Type == "sqlite" {
if !filepath.IsLocal(config.Database.LocalRef) {
return errors.New("SQLite 本地引用无效")
}
required = append(required, config.Database.LocalRef)
}
for _, name := range required {
base := filepath.Join(root, "data")
if strings.HasSuffix(name, ".key") {
base = filepath.Dir(KeyPath(root, name))
if DeploymentLayout(root) {
if err := safeDirectory(filepath.Join(root, "ssl")); err != nil {
return err
}
}
}
if err := safeDirectory(base); err != nil {
return err
}
parent := filepath.Dir(name)
for parent != "." {
if err := safeDirectory(filepath.Join(base, parent)); err != nil {
return err
}
parent = filepath.Dir(parent)
}
info, err := os.Lstat(filepath.Join(base, name))
if err != nil || !info.Mode().IsRegular() || info.Size() == 0 {
return fmt.Errorf("已初始化部署缺少必要运行文件,拒绝重新创建:%s", name)
}
}
return nil
}
// Migrate 必须停服调用;同卷重命名保留文件内容,失败逆序回滚,重试保持幂等。
func Migrate(root string) ([]Move, error) {
return migrate(root, os.Rename)
}
func migrate(root string, rename func(string, string) error) ([]Move, error) {
moves, err := Plan(root)
if err != nil {
return moves, err
}
directory, err := Directory(root)
if err != nil {
return nil, err
}
lockPath := filepath.Join(directory, migrationLock)
lock, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return nil, errors.New("无法取得迁移锁;请确认没有其他迁移进程,并人工核对中断现场")
}
if err := lock.Close(); err != nil {
return nil, err
}
keepLock := false
defer func() {
if !keepLock {
_ = os.Remove(lockPath)
}
}()
// 取得独占锁后重新检查,避免两个迁移进程使用同一份旧计划。
moves, err = Plan(root)
if err != nil {
return nil, err
}
var completed []Move
for _, move := range moves {
if err = os.MkdirAll(filepath.Dir(move.Target), 0700); err == nil {
err = rename(move.Source, move.Target)
}
if err != nil {
for index := len(completed) - 1; index >= 0; index-- {
item := completed[index]
rollbackErr := rename(item.Target, item.Source)
keepLock = keepLock || rollbackErr != nil
err = errors.Join(err, rollbackErr)
}
return nil, fmt.Errorf("运行数据迁移失败,已尝试回滚:%w", err)
}
completed = append(completed, move)
}
return completed, nil
}