参数化数据库复制并记录资源清理失败
This commit is contained in:
parent
3e135e2b7b
commit
aa351dcb9d
19
backend/config/resource_cleanup.go
Normal file
19
backend/config/resource_cleanup.go
Normal 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("关闭配置资源失败,请检查文件句柄和数据库连接状态")
|
||||||
|
}
|
||||||
|
}
|
||||||
54
backend/config/resource_cleanup_test.go
Normal file
54
backend/config/resource_cleanup_test.go
Normal 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("成功关闭资源不应记录错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@ -99,10 +100,9 @@ func stageFlatSSLAtRoot(root, mode string, privateKey, certificate, issuer []byt
|
|||||||
}
|
}
|
||||||
unlock, err := lockStateFile(lease)
|
unlock, err := lockStateFile(lease)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lease.Close()
|
return ManagedSSLReference{}, errors.Join(err, lease.Close())
|
||||||
return ManagedSSLReference{}, err
|
|
||||||
}
|
}
|
||||||
managedSSLStorage.staged[publicPath] = func() { unlock(); lease.Close() }
|
managedSSLStorage.staged[publicPath] = func() { releaseConfigLease(unlock, lease) }
|
||||||
complete = true
|
complete = true
|
||||||
return ManagedSSLReference{Mode: mode, CertificateRef: "ssl/" + base + ".crt", PrivateKeyRef: "ssl/" + base + ".key", CertificatePath: publicPath, PrivateKeyPath: privatePath}, nil
|
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)
|
unlock, err := lockStateFile(lease)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lease.Close()
|
if closeErr := lease.Close(); closeErr != nil {
|
||||||
|
return errors.Join(err, closeErr)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
err = storage.Remove(filepath.Base(privatePath))
|
err = storage.Remove(filepath.Base(privatePath))
|
||||||
@ -212,8 +214,7 @@ func pruneFlatSSLLocked(root string, protected []SSLConfig) error {
|
|||||||
err = storage.Remove(filepath.Base(publicPath))
|
err = storage.Remove(filepath.Base(publicPath))
|
||||||
}
|
}
|
||||||
unlock()
|
unlock()
|
||||||
lease.Close()
|
if err := errors.Join(err, lease.Close()); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -218,9 +218,11 @@ func pruneManagedSSLLocked(root string, protected []SSLConfig) error {
|
|||||||
unlock, err := lockStateFile(lease)
|
unlock, err := lockStateFile(lease)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
keep[path] = true
|
keep[path] = true
|
||||||
lease.Close()
|
if closeErr := lease.Close(); closeErr != nil {
|
||||||
|
return errors.Join(err, closeErr)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
leases = append(leases, func() { unlock(); lease.Close() })
|
leases = append(leases, func() { releaseConfigLease(unlock, lease) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
versions = append(versions, retainedSSLVersion{directory: path, modified: info.ModTime()})
|
versions = append(versions, retainedSSLVersion{directory: path, modified: info.ModTime()})
|
||||||
|
|||||||
@ -164,16 +164,13 @@ func importManagedSSLAtRoot(root string, mode string, privateKeyPEM, certificate
|
|||||||
return atomicfile.WriteRoot(candidateRoot, name, data, permission)
|
return atomicfile.WriteRoot(candidateRoot, name, data, permission)
|
||||||
}
|
}
|
||||||
if err := write("private.key", privateKeyPEM, 0600); err != nil {
|
if err := write("private.key", privateKeyPEM, 0600); err != nil {
|
||||||
candidateRoot.Close()
|
return ManagedSSLReference{}, errors.Join(err, candidateRoot.Close())
|
||||||
return ManagedSSLReference{}, err
|
|
||||||
}
|
}
|
||||||
if err := write("public.crt", certificatePEM, 0600); err != nil {
|
if err := write("public.crt", certificatePEM, 0600); err != nil {
|
||||||
candidateRoot.Close()
|
return ManagedSSLReference{}, errors.Join(err, candidateRoot.Close())
|
||||||
return ManagedSSLReference{}, err
|
|
||||||
}
|
}
|
||||||
if err := write("issuer.crt", issuerPEM, 0600); err != nil {
|
if err := write("issuer.crt", issuerPEM, 0600); err != nil {
|
||||||
candidateRoot.Close()
|
return ManagedSSLReference{}, errors.Join(err, candidateRoot.Close())
|
||||||
return ManagedSSLReference{}, err
|
|
||||||
}
|
}
|
||||||
if err := candidateRoot.Close(); err != nil {
|
if err := candidateRoot.Close(); err != nil {
|
||||||
return ManagedSSLReference{}, err
|
return ManagedSSLReference{}, err
|
||||||
@ -557,10 +554,9 @@ func stageManagedSSLAtRoot(root string, mode string, privateKeyPEM, certificateP
|
|||||||
}
|
}
|
||||||
unlock, err := lockStateFile(lease)
|
unlock, err := lockStateFile(lease)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lease.Close()
|
return ManagedSSLReference{}, errors.Join(err, lease.Close())
|
||||||
return ManagedSSLReference{}, err
|
|
||||||
}
|
}
|
||||||
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
|
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 {
|
for _, entry := range entries {
|
||||||
if err := storage.Remove(entry.Name()); err != nil {
|
if err := storage.Remove(entry.Name()); err != nil {
|
||||||
storage.Close()
|
return errors.Join(err, storage.Close())
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := storage.Close(); err != nil {
|
if err := storage.Close(); err != nil {
|
||||||
|
|||||||
@ -163,19 +163,18 @@ func (s *TransactionStore) openState() (*stateDatabase, error) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := database.PingContext(ctx); err != nil {
|
if err := database.PingContext(ctx); err != nil {
|
||||||
database.Close()
|
closeConfigResource(database)
|
||||||
return nil, ErrStateDatabaseUnavailable
|
return nil, ErrStateDatabaseUnavailable
|
||||||
}
|
}
|
||||||
if !s.legacyState {
|
if !s.legacyState {
|
||||||
if _, err := database.Exec(configstate.Schema(driver)); err != nil {
|
if _, err := database.Exec(configstate.Schema(driver)); err != nil {
|
||||||
database.Close()
|
closeConfigResource(database)
|
||||||
return nil, ErrStateDatabaseUnavailable
|
return nil, ErrStateDatabaseUnavailable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if filename != "" {
|
if filename != "" {
|
||||||
if err := os.Chmod(filename, 0600); err != nil {
|
if err := os.Chmod(filename, 0600); err != nil {
|
||||||
database.Close()
|
return nil, errors.Join(err, database.Close())
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return database, nil
|
return database, nil
|
||||||
|
|||||||
@ -37,10 +37,9 @@ func (s *TransactionStore) lockState() (func(), error) {
|
|||||||
}
|
}
|
||||||
unlock, err := lockStateFile(file)
|
unlock, err := lockStateFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
file.Close()
|
return nil, errors.Join(errors.New("配置状态正在被其他进程使用,请稍后重试"), file.Close())
|
||||||
return nil, errors.New("配置状态正在被其他进程使用,请稍后重试")
|
|
||||||
}
|
}
|
||||||
return func() { unlock(); file.Close() }, nil
|
return func() { releaseConfigLease(unlock, file) }, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TransactionStore) saveState(kind, id string, record stateRecord) error {
|
func (s *TransactionStore) saveState(kind, id string, record stateRecord) error {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package res_init
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@ -206,8 +207,7 @@ func Init(serverVersion string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := startAdminResetControl(); err != nil {
|
if err := startAdminResetControl(); err != nil {
|
||||||
stopServices()
|
return errors.Join(err, stopServices())
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
serviceErrors := make(chan error, 9)
|
serviceErrors := make(chan error, 9)
|
||||||
adminResetErrors := adminResetServeErrors
|
adminResetErrors := adminResetServeErrors
|
||||||
@ -221,13 +221,11 @@ func Init(serverVersion string) error {
|
|||||||
hooks.Init(serverVersion)
|
hooks.Init(serverVersion)
|
||||||
outboxRuntime, err = outbox.Start(db.Instance)
|
outboxRuntime, err = outbox.Start(db.Instance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
stopServices()
|
return errors.Join(fmt.Errorf("启动持久化发件队列失败: %w", err), stopServices())
|
||||||
return fmt.Errorf("start persistent outbox: %w", err)
|
|
||||||
}
|
}
|
||||||
httpRun, httpsRun, err := http_server.PrepareWebServers()
|
httpRun, httpsRun, err := http_server.PrepareWebServers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
stopServices()
|
return errors.Join(fmt.Errorf("准备网页服务失败: %w", err), stopServices())
|
||||||
return fmt.Errorf("prepare web services: %w", err)
|
|
||||||
}
|
}
|
||||||
mailSecurity := config.CurrentMailSecurity()
|
mailSecurity := config.CurrentMailSecurity()
|
||||||
// SMTP 25 用于服务器间投递,始终保留并支持机会性 STARTTLS。
|
// SMTP 25 用于服务器间投递,始终保留并支持机会性 STARTTLS。
|
||||||
@ -244,8 +242,7 @@ func Init(serverVersion string) error {
|
|||||||
|
|
||||||
// 等待 HTTP 服务启动后再执行需要 HTTP 回调的 ACME 操作。
|
// 等待 HTTP 服务启动后再执行需要 HTTP 回调的 ACME 操作。
|
||||||
if err := waitHTTPReady(serviceErrors); err != nil {
|
if err := waitHTTPReady(serviceErrors); err != nil {
|
||||||
stopServices()
|
return errors.Join(err, stopServices())
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新 SSL 配置。
|
// 更新 SSL 配置。
|
||||||
@ -269,8 +266,7 @@ func Init(serverVersion string) error {
|
|||||||
select {
|
select {
|
||||||
case err := <-serviceErrors:
|
case err := <-serviceErrors:
|
||||||
log.WithError(err).WithFields(log.Fields{"action": "运行邮箱服务", "result": "failure"}).Error("邮箱服务运行失败")
|
log.WithError(err).WithFields(log.Fields{"action": "运行邮箱服务", "result": "failure"}).Error("邮箱服务运行失败")
|
||||||
stopServices()
|
return errors.Join(err, stopServices())
|
||||||
return err
|
|
||||||
case <-signal.RestartChan:
|
case <-signal.RestartChan:
|
||||||
log.WithFields(log.Fields{"action": "重启服务", "result": "started"}).Info("已收到服务重启请求")
|
log.WithFields(log.Fields{"action": "重启服务", "result": "started"}).Info("已收到服务重启请求")
|
||||||
if err := stopServices(); err != nil {
|
if err := stopServices(); err != nil {
|
||||||
|
|||||||
@ -288,8 +288,7 @@ func vacuumSQLite(ctx context.Context, source, target string) error {
|
|||||||
if err := database.PingContext(ctx); err != nil {
|
if err := database.PingContext(ctx); err != nil {
|
||||||
return fmt.Errorf("ping sqlite source: %w", err)
|
return fmt.Errorf("ping sqlite source: %w", err)
|
||||||
}
|
}
|
||||||
query := "VACUUM INTO '" + strings.ReplaceAll(target, "'", "''") + "'"
|
if _, err := database.ExecContext(ctx, "VACUUM INTO ?", target); err != nil {
|
||||||
if _, err := database.ExecContext(ctx, query); err != nil {
|
|
||||||
return fmt.Errorf("copy sqlite database: %w", err)
|
return fmt.Errorf("copy sqlite database: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -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) {
|
func TestSQLiteExecutorSupportsNestedReferences(t *testing.T) {
|
||||||
var switched config.DatabaseConfig
|
var switched config.DatabaseConfig
|
||||||
service, root := newSQLiteMigrationFixture(t, func(_ context.Context, candidate config.DatabaseConfig) error {
|
service, root := newSQLiteMigrationFixture(t, func(_ context.Context, candidate config.DatabaseConfig) error {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user