Smail/backend/utils/runtimepath/business_keys.go
smail 9dc861d560
Some checks are pending
Security Checks / security (push) Waiting to run
角色权限收紧前保存证书直接引用与续期热切换改造
2026-09-09 17:42:19 +08:00

138 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package runtimepath
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"git.pcux.cn/xuans/Smail/utils/atomicfile"
)
// BusinessKeyNames 返回固定业务密钥清单,不能由外部输入扩展迁移范围。
func BusinessKeyNames() []string {
return []string{"credential.key", "external-email-encryption.key", "external-email-fingerprint.key"}
}
func businessKeyFolder(name string) string {
switch name {
case "credential.key":
return "credentials"
case "external-email-encryption.key":
return "external-email-encryption"
case "external-email-fingerprint.key":
return "external-email-fingerprint"
default:
panic("unknown business key")
}
}
func groupedKeyPath(root, name string) string {
return filepath.Join(root, "ssl", businessKeyFolder(name), name)
}
// KeyPath 兼容尚未升级的部署;正式启动完成归档后只使用业务子目录。
func KeyPath(root, name string) string {
folder := businessKeyFolder(name)
if !DeploymentLayout(root) {
return filepath.Join(root, "data", name)
}
if _, err := os.Lstat(filepath.Join(root, "ssl", name)); err == nil {
return filepath.Join(root, "ssl", name)
}
if _, err := os.Lstat(filepath.Join(root, "ssl", folder)); !os.IsNotExist(err) {
return groupedKeyPath(root, name)
}
return filepath.Join(root, "ssl", name)
}
// SecureKeyDirectory 逐层验证目录,不能通过父目录链接写入外部位置。
func SecureKeyDirectory(root, name string) (string, error) {
if err := CheckKeyLayout(root); err != nil {
return "", err
}
directory := filepath.Dir(KeyPath(root, name))
base := "data"
if DeploymentLayout(root) {
base = "ssl"
}
parent, err := SecureDirectory(root, base)
if err != nil {
return "", err
}
if filepath.Clean(directory) == filepath.Clean(parent) {
return parent, nil
}
return SecureDirectory(parent, businessKeyFolder(name))
}
// PrepareDeploymentKeys 由主程序启动入口在独占状态锁内调用,不依赖安装脚本。
// 每次重命名保留原字节;中断后的混合位置可在下次启动继续迁移。
func PrepareDeploymentKeys(root string) error {
if !DeploymentLayout(root) {
return nil
}
if err := CheckReady(root); err != nil {
return err
}
var moves []Move
for _, name := range BusinessKeyNames() {
source := KeyPath(root, name)
info, err := os.Lstat(source)
if os.IsNotExist(err) {
continue
}
if err != nil || !info.Mode().IsRegular() || info.Size() != 32 {
return fmt.Errorf("业务密钥无效,拒绝迁移:%s", name)
}
if err := safeDirectory(filepath.Join(root, "ssl")); err != nil {
return err
}
if err := safeDirectory(filepath.Dir(source)); err != nil {
return err
}
if runtime.GOOS != "windows" && info.Mode().Perm()&0077 != 0 {
return errors.New("业务密钥权限过于宽松,拒绝迁移")
}
if source != groupedKeyPath(root, name) {
moves = append(moves, Move{Source: source, Target: groupedKeyPath(root, name)})
}
}
ssl, err := SecureDirectory(root, "ssl")
if err != nil {
return err
}
// 先检查所有目标,不能移动第一把密钥后才发现另一业务目录不安全。
for _, folder := range []string{"credentials", "external-email-encryption", "external-email-fingerprint", "dkim"} {
if err := safeDirectory(filepath.Join(ssl, folder)); err != nil && !os.IsNotExist(err) {
return err
}
}
for _, folder := range []string{"config", "log"} {
if _, err := SecureDirectory(root, folder); err != nil {
return err
}
}
for _, folder := range []string{"credentials", "external-email-encryption", "external-email-fingerprint", "dkim"} {
if _, err := SecureDirectory(ssl, folder); err != nil {
return err
}
}
for _, move := range moves {
if _, err := os.Lstat(move.Target); !os.IsNotExist(err) {
return errors.New("密钥迁移目标已存在,拒绝覆盖")
}
if err := os.Rename(move.Source, move.Target); err != nil {
return fmt.Errorf("业务密钥归档未完成,下次启动将继续:%w", err)
}
if err := atomicfile.SyncDirectory(filepath.Dir(move.Target)); err != nil {
return err
}
if err := atomicfile.SyncDirectory(ssl); err != nil {
return err
}
}
return CheckKeyLayout(root)
}