参数化数据库复制并记录资源清理失败
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-21 00:47:38 +08:00
parent 7fc0e1ca8f
commit a74e8100d3
10 changed files with 129 additions and 38 deletions

View File

@ -0,0 +1,19 @@
package config
import (
"io"
log "github.com/sirupsen/logrus"
)
// 无返回值的租约释放接口记录关闭失败,不将路径或驱动错误中的敏感信息写入日志。
func releaseConfigLease(unlock func(), lease io.Closer) {
unlock()
closeConfigResource(lease)
}
func closeConfigResource(resource io.Closer) {
if err := resource.Close(); err != nil {
log.WithField("action", "释放配置资源").Error("关闭配置资源失败,请检查文件句柄和数据库连接状态")
}
}

View File

@ -0,0 +1,54 @@
package config
import (
"errors"
"fmt"
"strings"
"testing"
log "github.com/sirupsen/logrus"
logtest "github.com/sirupsen/logrus/hooks/test"
)
type cleanupTestResource struct {
close func() error
}
func (r cleanupTestResource) Close() error { return r.close() }
func TestReleaseConfigLeaseUnlocksBeforeClosing(t *testing.T) {
var steps []string
resource := cleanupTestResource{close: func() error {
steps = append(steps, "close")
return nil
}}
releaseConfigLease(func() { steps = append(steps, "unlock") }, resource)
if strings.Join(steps, ",") != "unlock,close" {
t.Fatalf("资源释放顺序不正确: %v", steps)
}
}
func TestCloseConfigResourceReportsFailureWithoutSensitiveDetails(t *testing.T) {
previous := log.StandardLogger().ReplaceHooks(make(log.LevelHooks))
t.Cleanup(func() { log.StandardLogger().ReplaceHooks(previous) })
hook := logtest.NewGlobal()
const sensitive = "sensitive-cleanup-value"
closeCount := 0
resource := cleanupTestResource{close: func() error {
closeCount++
return errors.New(sensitive)
}}
closeConfigResource(resource)
entry := hook.LastEntry()
if closeCount != 1 || entry == nil || entry.Level != log.ErrorLevel {
t.Fatal("关闭失败没有记录为错误")
}
if strings.Contains(entry.Message+fmt.Sprint(entry.Data), sensitive) {
t.Fatal("关闭错误日志泄露了资源内部信息")
}
hook.Reset()
closeConfigResource(cleanupTestResource{close: func() error { return nil }})
if hook.LastEntry() != nil {
t.Fatal("成功关闭资源不应记录错误")
}
}

View File

@ -2,6 +2,7 @@ package config
import (
"crypto/tls"
"errors"
"os"
"path/filepath"
"strings"
@ -99,10 +100,9 @@ func stageFlatSSLAtRoot(root, mode string, privateKey, certificate, issuer []byt
}
unlock, err := lockStateFile(lease)
if err != nil {
lease.Close()
return ManagedSSLReference{}, err
return ManagedSSLReference{}, errors.Join(err, lease.Close())
}
managedSSLStorage.staged[publicPath] = func() { unlock(); lease.Close() }
managedSSLStorage.staged[publicPath] = func() { releaseConfigLease(unlock, lease) }
complete = true
return ManagedSSLReference{Mode: mode, CertificateRef: "ssl/" + base + ".crt", PrivateKeyRef: "ssl/" + base + ".key", CertificatePath: publicPath, PrivateKeyPath: privatePath}, nil
}
@ -204,7 +204,9 @@ func pruneFlatSSLLocked(root string, protected []SSLConfig) error {
}
unlock, err := lockStateFile(lease)
if err != nil {
lease.Close()
if closeErr := lease.Close(); closeErr != nil {
return errors.Join(err, closeErr)
}
continue
}
err = storage.Remove(filepath.Base(privatePath))
@ -212,8 +214,7 @@ func pruneFlatSSLLocked(root string, protected []SSLConfig) error {
err = storage.Remove(filepath.Base(publicPath))
}
unlock()
lease.Close()
if err != nil {
if err := errors.Join(err, lease.Close()); err != nil {
return err
}
}

View File

@ -218,9 +218,11 @@ func pruneManagedSSLLocked(root string, protected []SSLConfig) error {
unlock, err := lockStateFile(lease)
if err != nil {
keep[path] = true
lease.Close()
if closeErr := lease.Close(); closeErr != nil {
return errors.Join(err, closeErr)
}
} else {
leases = append(leases, func() { unlock(); lease.Close() })
leases = append(leases, func() { releaseConfigLease(unlock, lease) })
}
}
versions = append(versions, retainedSSLVersion{directory: path, modified: info.ModTime()})

View File

@ -164,16 +164,13 @@ func importManagedSSLAtRoot(root string, mode string, privateKeyPEM, certificate
return atomicfile.WriteRoot(candidateRoot, name, data, permission)
}
if err := write("private.key", privateKeyPEM, 0600); err != nil {
candidateRoot.Close()
return ManagedSSLReference{}, err
return ManagedSSLReference{}, errors.Join(err, candidateRoot.Close())
}
if err := write("public.crt", certificatePEM, 0600); err != nil {
candidateRoot.Close()
return ManagedSSLReference{}, err
return ManagedSSLReference{}, errors.Join(err, candidateRoot.Close())
}
if err := write("issuer.crt", issuerPEM, 0600); err != nil {
candidateRoot.Close()
return ManagedSSLReference{}, err
return ManagedSSLReference{}, errors.Join(err, candidateRoot.Close())
}
if err := candidateRoot.Close(); err != nil {
return ManagedSSLReference{}, err
@ -557,10 +554,9 @@ func stageManagedSSLAtRoot(root string, mode string, privateKeyPEM, certificateP
}
unlock, err := lockStateFile(lease)
if err != nil {
lease.Close()
return ManagedSSLReference{}, err
return ManagedSSLReference{}, errors.Join(err, lease.Close())
}
managedSSLStorage.staged[directory] = func() { unlock(); lease.Close() }
managedSSLStorage.staged[directory] = func() { releaseConfigLease(unlock, lease) }
return ManagedSSLReference{Mode: mode, CertificateRef: filepath.ToSlash(publicRef), PrivateKeyRef: filepath.ToSlash(privateRef), CertificatePath: publicPath, PrivateKeyPath: privatePath}, nil
}
@ -656,8 +652,7 @@ func removeManagedSSLDirectory(directory string) error {
}
for _, entry := range entries {
if err := storage.Remove(entry.Name()); err != nil {
storage.Close()
return err
return errors.Join(err, storage.Close())
}
}
if err := storage.Close(); err != nil {

View File

@ -163,19 +163,18 @@ func (s *TransactionStore) openState() (*stateDatabase, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := database.PingContext(ctx); err != nil {
database.Close()
closeConfigResource(database)
return nil, ErrStateDatabaseUnavailable
}
if !s.legacyState {
if _, err := database.Exec(configstate.Schema(driver)); err != nil {
database.Close()
closeConfigResource(database)
return nil, ErrStateDatabaseUnavailable
}
}
if filename != "" {
if err := os.Chmod(filename, 0600); err != nil {
database.Close()
return nil, err
return nil, errors.Join(err, database.Close())
}
}
return database, nil

View File

@ -37,10 +37,9 @@ func (s *TransactionStore) lockState() (func(), error) {
}
unlock, err := lockStateFile(file)
if err != nil {
file.Close()
return nil, errors.New("配置状态正在被其他进程使用,请稍后重试")
return nil, errors.Join(errors.New("配置状态正在被其他进程使用,请稍后重试"), file.Close())
}
return func() { unlock(); file.Close() }, nil
return func() { releaseConfigLease(unlock, file) }, nil
}
func (s *TransactionStore) saveState(kind, id string, record stateRecord) error {

View File

@ -2,6 +2,7 @@ package res_init
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
@ -206,8 +207,7 @@ func Init(serverVersion string) error {
return err
}
if err := startAdminResetControl(); err != nil {
stopServices()
return err
return errors.Join(err, stopServices())
}
serviceErrors := make(chan error, 9)
adminResetErrors := adminResetServeErrors
@ -221,13 +221,11 @@ func Init(serverVersion string) error {
hooks.Init(serverVersion)
outboxRuntime, err = outbox.Start(db.Instance)
if err != nil {
stopServices()
return fmt.Errorf("start persistent outbox: %w", err)
return errors.Join(fmt.Errorf("启动持久化发件队列失败: %w", err), stopServices())
}
httpRun, httpsRun, err := http_server.PrepareWebServers()
if err != nil {
stopServices()
return fmt.Errorf("prepare web services: %w", err)
return errors.Join(fmt.Errorf("准备网页服务失败: %w", err), stopServices())
}
mailSecurity := config.CurrentMailSecurity()
// SMTP 25 用于服务器间投递,始终保留并支持机会性 STARTTLS。
@ -244,8 +242,7 @@ func Init(serverVersion string) error {
// 等待 HTTP 服务启动后再执行需要 HTTP 回调的 ACME 操作。
if err := waitHTTPReady(serviceErrors); err != nil {
stopServices()
return err
return errors.Join(err, stopServices())
}
// 更新 SSL 配置。
@ -269,8 +266,7 @@ func Init(serverVersion string) error {
select {
case err := <-serviceErrors:
log.WithError(err).WithFields(log.Fields{"action": "运行邮箱服务", "result": "failure"}).Error("邮箱服务运行失败")
stopServices()
return err
return errors.Join(err, stopServices())
case <-signal.RestartChan:
log.WithFields(log.Fields{"action": "重启服务", "result": "started"}).Info("已收到服务重启请求")
if err := stopServices(); err != nil {

View File

@ -288,8 +288,7 @@ func vacuumSQLite(ctx context.Context, source, target string) error {
if err := database.PingContext(ctx); err != nil {
return fmt.Errorf("ping sqlite source: %w", err)
}
query := "VACUUM INTO '" + strings.ReplaceAll(target, "'", "''") + "'"
if _, err := database.ExecContext(ctx, query); err != nil {
if _, err := database.ExecContext(ctx, "VACUUM INTO ?", target); err != nil {
return fmt.Errorf("copy sqlite database: %w", err)
}
return nil

View File

@ -78,6 +78,33 @@ func TestSQLiteExecutorCopiesDatabaseAndSwitchesConfiguration(t *testing.T) {
}
}
func TestVacuumSQLiteBindsDestinationAsData(t *testing.T) {
for _, name := range []string{"copy with spaces.db", "copy'quote.db", "copy'; DROP TABLE messages; --.db"} {
t.Run(name, func(t *testing.T) {
_, root := newSQLiteMigrationFixture(t, nil)
source := filepath.Join(root, "data", "smail.db")
target := filepath.Join(root, "data", name)
if err := vacuumSQLite(context.Background(), source, target); err != nil {
t.Fatal(err)
}
for _, path := range []string{source, target} {
database, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
var body string
queryErr := database.QueryRow("SELECT body FROM messages WHERE id = 1").Scan(&body)
if err := errors.Join(queryErr, database.Close()); err != nil {
t.Fatal(err)
}
if body != "测试邮件" {
t.Fatal("源库或复制库的邮件内容被意外修改")
}
}
})
}
}
func TestSQLiteExecutorSupportsNestedReferences(t *testing.T) {
var switched config.DatabaseConfig
service, root := newSQLiteMigrationFixture(t, func(_ context.Context, candidate config.DatabaseConfig) error {