修复投递结果恢复并隔离远程验收资源
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-17 11:21:49 +08:00
parent 7f719ab79e
commit 1b2ba5a6a1
13 changed files with 511 additions and 34 deletions

View File

@ -96,6 +96,13 @@ func TestPersistPluginConfigReplacesFileAtomically(t *testing.T) {
if !RegisterHook("spam_block", &HookSender{name: "spam_block.exe"}) {
t.Fatal("plugin hook registration failed")
}
if runtime.GOOS == "linux" && os.Geteuid() != 0 {
// 普通用户创建的插件目录不满足生产所有者要求,必须拒绝写入。
if err := PersistPluginConfig("spam_block", `{"url":"https://model.example.com"}`); err == nil {
t.Fatal("non-root-owned plugin directory was accepted")
}
return
}
for _, payload := range []string{`{"url":"https://model.example.com"}`, `{"url":"https://model.example.com/v2"}`} {
if err = PersistPluginConfig("spam_block", payload); err != nil {
t.Fatal(err)

View File

@ -958,6 +958,9 @@ func TestIDLE(t *testing.T) {
t.Fatal(err)
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(15 * time.Second)); err != nil {
t.Fatal(err)
}
reader := bufio.NewReader(conn)
line, err := reader.ReadString('\n')

View File

@ -24,6 +24,13 @@ type Runtime struct {
done chan struct{}
once sync.Once
deliver func(*context.Context, *parsemail.Email, string) error
pending *pendingResult
}
type pendingResult struct {
job models.OutboundDelivery
ctx *context.Context
message *parsemail.Email
}
func Start(engine *xorm.Engine) (*Runtime, error) {
@ -58,20 +65,8 @@ func (r *Runtime) loop() {
return
default:
}
var jobs []models.OutboundDelivery
err := r.engine.Where("state = ? AND next_attempt <= ?", queued, time.Now().Unix()).Asc("id").Limit(20).Find(&jobs)
if err != nil {
log.WithError(err).Error("读取持久投递队列失败")
}
for _, job := range jobs {
select {
case <-r.stop:
return
default:
}
if err := r.process(job); err != nil {
log.WithError(err).WithField("delivery_id", job.ID).Error("处理持久投递队列失败")
}
if err := r.tick(); err != nil {
log.WithError(err).Error("处理持久投递队列失败")
}
select {
case <-r.stop:
@ -81,12 +76,37 @@ func (r *Runtime) loop() {
}
}
func (r *Runtime) tick() error {
// 单工作器最多保留一个未落库结果,恢复前不领取新任务,避免内存积压和重复投递。
if r.pending != nil {
return dbmigration.WhileWritable(r.flushResult)
}
var jobs []models.OutboundDelivery
if err := r.engine.Where("state = ? AND next_attempt <= ?", queued, time.Now().Unix()).Asc("id").Limit(20).Find(&jobs); err != nil {
return err
}
for _, job := range jobs {
select {
case <-r.stop:
return nil
default:
}
if err := r.process(job); err != nil {
return fmt.Errorf("投递任务 %d: %w", job.ID, err)
}
}
return nil
}
func (r *Runtime) process(job models.OutboundDelivery) error {
// 迁移必须等待 SMTP 及结果落库结束,冻结源库后不再领取任务。
return dbmigration.WhileWritable(func() error { return r.processWritable(job) })
}
func (r *Runtime) processWritable(job models.OutboundDelivery) error {
if r.pending != nil {
return r.flushResult()
}
// 读取失败时尚未开始投递,保留排队状态供数据库恢复后继续处理。
var stored models.Email
emailFound, err := r.engine.ID(job.EmailID).Get(&stored)
@ -126,20 +146,23 @@ func (r *Runtime) processWritable(job models.OutboundDelivery) error {
}
return r.deliver(ctx, message, job.Recipient)
}()
// 只重试结果落库,绝不因数据库故障再次调用 SMTP。
var terminal bool
for attempt := 0; attempt < 3; attempt++ {
terminal, err = finish(r.engine, job, deliveryErr)
if err == nil {
break
}
time.Sleep(time.Second)
// 固定首次结果及退避时间,后续只重试落库,绝不再次调用 SMTP。
r.pending = &pendingResult{job: deliveryResult(job, deliveryErr), ctx: ctx, message: message}
return r.flushResult()
}
func (r *Runtime) flushResult() error {
result := r.pending
if result == nil {
return nil
}
terminal, err := persistResult(r.engine, result.job)
if err != nil {
return fmt.Errorf("投递结果写入失败,保留在途记录并禁止自动重投: %w", err)
return fmt.Errorf("投递任务 %d 结果写入失败,已暂停领取任务并等待数据库恢复: %w", result.job.ID, err)
}
if terminal && message != nil {
r.notify(ctx, message, job.EmailID)
r.pending = nil
if terminal && result.message != nil {
r.notify(result.ctx, result.message, result.job.EmailID)
}
return nil
}

View File

@ -151,6 +151,10 @@ func claim(engine *xorm.Engine, job *models.OutboundDelivery) (bool, error) {
}
func finish(engine *xorm.Engine, job models.OutboundDelivery, deliveryErr error) (bool, error) {
return persistResult(engine, deliveryResult(job, deliveryErr))
}
func deliveryResult(job models.OutboundDelivery, deliveryErr error) models.OutboundDelivery {
job.State, job.LastError, job.NextAttempt = accepted, "", 0
if deliveryErr != nil {
// 不保存远端响应原文,避免凭据或恶意超长内容进入数据库和页面。
@ -163,6 +167,10 @@ func finish(engine *xorm.Engine, job models.OutboundDelivery, deliveryErr error)
job.NextAttempt = time.Now().Add(time.Minute * time.Duration(1<<(job.Attempts-1))).Unix()
}
}
return job
}
func persistResult(engine *xorm.Engine, job models.OutboundDelivery) (bool, error) {
terminal := false
err := db.Transaction(engine, func(session *xorm.Session) error {
count, err := session.ID(job.ID).Where("state = ? AND attempts = ?", running, job.Attempts).Cols("state", "last_error", "next_attempt").Update(&job)
@ -170,7 +178,17 @@ func finish(engine *xorm.Engine, job models.OutboundDelivery, deliveryErr error)
return err
}
if count != 1 {
return errors.New("投递任务状态已发生变化")
// 提交响应丢失时,确认同一次尝试的相同结果已落库,不覆盖后续尝试。
var stored models.OutboundDelivery
found, readErr := session.ID(job.ID).Get(&stored)
if readErr != nil {
return readErr
}
if !found || stored.EmailID != job.EmailID || stored.Recipient != job.Recipient ||
stored.Attempts != job.Attempts || stored.State != job.State ||
stored.LastError != job.LastError || stored.NextAttempt != job.NextAttempt {
return errors.New("投递任务状态已发生变化")
}
}
terminal, err = syncStatus(session, job.EmailID)
return err

View File

@ -173,8 +173,8 @@ func TestResultFailureRollsBackAndNeverRedelivers(t *testing.T) {
if err := r.process(jobs[0]); err == nil {
t.Fatal("result failure was ignored")
}
if err := r.process(jobs[0]); err != nil {
t.Fatal(err)
if err := r.tick(); err == nil {
t.Fatal("result retry failure was ignored")
}
if calls.Load() != 1 {
t.Fatalf("SMTP called %d times", calls.Load())
@ -187,6 +187,77 @@ func TestResultFailureRollsBackAndNeverRedelivers(t *testing.T) {
if job.State != running {
t.Fatalf("state=%s", job.State)
}
if _, err := engine.Exec("DROP TRIGGER reject_result"); err != nil {
t.Fatal(err)
}
if err := r.tick(); err != nil {
t.Fatal(err)
}
if r.pending != nil || calls.Load() != 1 {
t.Fatal("result was not recovered without another SMTP attempt")
}
var persisted models.OutboundDelivery
if found, err := engine.ID(job.ID).Get(&persisted); err != nil || !found || persisted.State != accepted {
t.Fatalf("result not persisted: %+v %v", persisted, err)
}
}
func TestPersistResultAfterLostCommitResponse(t *testing.T) {
for _, deliveryErr := range []error{nil, &textproto.Error{Code: 451, Msg: "later"}, &smtp.DeliveryUncertainError{Cause: io.EOF}} {
engine, _, jobs := queueFixture(t)
job := jobs[0]
if ok, err := claim(engine, &job); err != nil || !ok {
t.Fatal(err)
}
result := deliveryResult(job, deliveryErr)
if _, err := persistResult(engine, result); err != nil {
t.Fatal(err)
}
if _, err := persistResult(engine, result); err != nil {
t.Fatalf("same result must be idempotent: %v", err)
}
result.Attempts++
if _, err := persistResult(engine, result); err == nil {
t.Fatal("different attempt must not overwrite persisted result")
}
}
}
func TestPendingResultStopsNewClaimsAndSurvivesRestartSafely(t *testing.T) {
engine, _, jobs := queueFixture(t)
if _, err := engine.Exec("CREATE TRIGGER reject_result BEFORE UPDATE ON user_email BEGIN SELECT RAISE(ABORT, 'injected failure'); END"); err != nil {
t.Fatal(err)
}
var calls int
r := &Runtime{engine: engine, deliver: func(_ *context.Context, _ *parsemail.Email, _ string) error {
calls++
return nil
}}
for i := 0; i < 4; i++ {
if err := r.tick(); err == nil {
t.Fatal("persistence failure ignored")
}
}
var second models.OutboundDelivery
if _, err := engine.ID(jobs[1].ID).Get(&second); err != nil {
t.Fatal(err)
}
if calls != 1 || second.State != queued || second.Attempts != 0 {
t.Fatalf("new delivery started during outage: calls=%d job=%+v", calls, second)
}
if _, err := engine.Exec("DROP TRIGGER reject_result"); err != nil {
t.Fatal(err)
}
if err := recoverInterrupted(engine); err != nil {
t.Fatal(err)
}
var first models.OutboundDelivery
if _, err := engine.ID(jobs[0].ID).Get(&first); err != nil || first.State != uncertain {
t.Fatalf("lost in-memory result must remain uncertain: %+v %v", first, err)
}
if _, err := persistResult(engine, r.pending.job); err == nil {
t.Fatal("stale runtime overwrote restart recovery")
}
}
func TestQueuedJobsSurviveRestartAndDrain(t *testing.T) {

View File

@ -203,6 +203,10 @@ func isAbsoluteUserCertificatePath(value string) bool {
if filepath.IsAbs(value) {
return true
}
// 非 Windows 主机也要识别盘符,避免把异平台路径静默拼到项目目录。
if len(value) >= 2 && value[1] == ':' && ((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) {
return true
}
volume := filepath.VolumeName(value)
return volume != "" || strings.HasPrefix(value, `\\`) || strings.HasPrefix(value, "//")
}
@ -218,8 +222,8 @@ func validateUserCertificatePaths(privatePath, publicPath string) (string, strin
}
path := strings.TrimSpace(value)
if isAbsoluteUserCertificatePath(path) {
if filepath.VolumeName(path) != "" && !filepath.IsAbs(path) {
return "", nil, newUserCertificateError("SSL 路径必须是完整的绝对路径")
if !filepath.IsAbs(path) {
return "", nil, newUserCertificateError("SSL 路径必须是当前服务器支持的完整绝对路径")
}
} else {
path = filepath.Join(rootPath, path)

View File

@ -54,12 +54,13 @@ func TestIsAbsoluteUserCertificatePathRecognizesWindowsPaths(t *testing.T) {
cases := []string{
`C:\outside\private.key`,
`D:/outside/public.crt`,
`C:private.key`,
`\\server\share\private.key`,
`//server/share/public.crt`,
}
for _, value := range cases {
if !isAbsoluteUserCertificatePath(value) {
t.Errorf("path %q was accepted", value)
t.Errorf("path %q was not recognized", value)
}
}
}
@ -73,6 +74,17 @@ func TestIsAbsoluteUserCertificatePathRejectsEmptyAndAcceptsRelative(t *testing.
}
}
func TestValidateUserCertificatePathsRejectsNonNativeAbsolutePath(t *testing.T) {
for _, path := range []string{`C:private.key`, `C:\outside\private.key`, `\\server\share\private.key`} {
if filepath.IsAbs(path) {
continue
}
if _, _, err := validateUserCertificatePaths(path, path); err == nil || err.Error() != "SSL 路径必须是当前服务器支持的完整绝对路径" {
t.Fatalf("non-native path %q: %v", path, err)
}
}
}
func TestApplyManagedSSLReferenceDoesNotPersistLegacyPaths(t *testing.T) {
cfg := &config.Config{
SSLPrivateKeyPath: "config/ssl/private.key",

View File

@ -18,10 +18,13 @@
| PROD-006 | P0 | 可信 TLS 和正式域名验证 | [!] | 仍使用自签名测试证书,不关闭证书校验 |
| PROD-007 | P0 | 完整网页登录、发信和状态核对 | [!] | 需要有效测试账号和可信证书;不得绕过登录 |
| PROD-008 | P0 | 超级管理员恢复完整交互 | [!] | IPC 权限已复核,正式密码代理交互及旧会话撤销未验收 |
| PROD-009 | P1 | 备份与恢复演练 | [x] | 完整备份、归档及双库校验通过;异机恢复未演练 |
| PROD-009 | P1 | 备份与恢复演练 | [x] | 同机独立网络和挂载空间恢复、HTTPS、内部 SMTP 入库通过;异机恢复未演练 |
| PROD-010 | P1 | 内部 SMTP 与部署后安全回归 | [✓] | 五项真实收信、双库、产物、未认证 API 均通过 |
| PROD-011 | P1 | 全量测试、竞态、数据库兼容性和压测 | [ ] | 本轮仅七包 132 个顶层用例;未运行真实 MySQL/PostgreSQL |
| PROD-011 | P1 | 全量测试、竞态、数据库兼容性和压测 | [~] | 扩展至 60 包远程复测;竞态、真实 MySQL/PostgreSQL 和容量验收未完成 |
| PROD-012 | P1 | 最新版本清库首次安装验收 | [ ] | 当前为保留数据升级,不能沿用旧版本清库结果 |
| PROD-013 | P0 | 共享主机资源隔离与容量 | [!] | 首轮测试期间发生全机 OOM,MySQL 被终止后自动重启;必须补齐资源规划 |
| PROD-014 | P1 | 投递结果写库恢复 | [x] | 数据库故障后仅重试结果落库、停止新领取,12 项远程队列测试通过 |
| PROD-015 | P1 | 跨平台证书路径与插件测试 | [✓] | Linux 拒绝异平台路径误解析;保持证书校验、托管导入和插件目录权限要求 |
状态含义:`[ ]` 未开始,`[~]` 进行中,`[x]` 已完成待复审,`[✓]` 指定范围复审通过,`[!]` 暂缓或存在阻塞。
@ -47,7 +50,7 @@ SSH 脚本复用 `test/tools/remote_ssh.py`,强制核对已知主机密钥。
- 每收件人独立记录 `queued`、`running`、`accepted`、`failed` 或 `uncertain`。成功收件人不随其他收件人的失败重投。
- 临时失败最多五次,间隔 1、2、4、8 分钟;明确拒绝、证书问题、危险目标和结果不确定停止自动重试。
- DATA 结果不确定、重启时仍在途、旧版本没有可靠尝试记录的邮件必须先核对实际收件结果,再决定人工处理。当前没有已验收的人工重投界面,不要直接改库重排。
- 数据库读取失败不领取任务;结果写库失败只重试写入三次,仍失败则保留在途状态并记录错误。恢复数据库后需要核查日志及受控重启,不能把它理解成无限自动恢复。
- 新版数据库读取失败不领取任务;结果写库失败时单工作器最多保留一份结果,暂停新领取,每秒仅重试写库,不再次调用 SMTP。数据库恢复后自动保存;进程中途退出仍将遗留在途记录转为待核对。相同尝试的相同结果允许重复保存,不覆盖后续尝试;线上部署状态以最新测试记录为准。
- 当前仅允许单实例、单工作器使用同一队列库。没有验收多实例调度、吞吐容量、队列告警和故障期间的服务等级。
- 现有邮件客户端 SMTP 提交、系统通知和其他调用方不自动等同于网页持久队列;分别验收其投递及失败处理。
- 备用 MX 保留既有 25 STARTTLS、587 STARTTLS、465 TLS 顺序;禁止明文降级。各运营商的端口策略及可达性需要独立验证。
@ -61,3 +64,9 @@ SSH 脚本复用 `test/tools/remote_ssh.py`,强制核对已知主机密钥。
5. 在隔离远程目标完成备份恢复、全量与竞态测试、目标数据库兼容性和容量验证。最新版本重新清库安装前,先备份所有项目数据库并核实范围,不清理其他项目数据。
上述阻塞项解决后再做最终生产评审,不以构建成功、首页 200、内部 SMTP 250 或单次提交代替整体验收。
## 资源与测试隔离
47 服务器约 1.6 GiB 内存,无交换空间,另有 MySQL、Scert 等服务。首轮测试仅设置 Go 软内存上限,不能防止全机内存耗尽。新工具通过 systemd 设置 CPU、内存、进程数和最长运行时间上限,使用独立网络、私有临时目录,并禁止访问线上 `/opt/smail`、`/run/smail`。可用内存不足时拒绝开始测试,不自动放宽限制、不停止其他项目。
`GOMEMLIMIT` 是 Go 回收目标,不是进程或主机硬限制。本轮协议测试以 128 MiB cgroup 上限、25% CPU 配额和 64 MiB Go 回收目标完成,不能据此推导生产容量。生产仍需确定同机服务预算、并发认证开销、邮件大小与吞吐上限、告警和故障恢复指标。

View File

@ -233,3 +233,47 @@ TLS 前未公布 AUTH,主动尝试明文 AUTH 返回 523。未认证外域邮
- [!] 受信任 TLS、凭据轮换、正式管理员恢复交互、新密码登录与旧会话撤销、灾备恢复及全量兼容性验证仍未完成。
当前结论仍为:适合继续受控远程验收,不建议直接用于正式生产。后续门槛见《生产整改验收清单》。
## 2026 年 9 月 17 日投递结果恢复与扩展验收
本轮从 10:30 开始。继续只在 47 服务器执行业务和自动测试,本机仅源码检查、交叉编译与 SSH 操作。没有清理线上双库,没有删除历史日志和备份,没有修改其他项目配置。修改前检查点 `d3d4f5d`、`7f719ab` 已推送。
### 本轮整改
- 单工作器保留一个未落库投递结果;数据库故障期间停止新领取,恢复后只保存结果,不重复发送 SMTP。首次结果与退避时间固定;相同尝试的相同结果支持幂等保存,旧尝试不得覆盖新状态。
- Linux 识别 Windows 盘符路径并明确拒绝非本机绝对路径,避免错误地拼接至项目目录。有效本机任意路径、证书及密钥校验和托管导入继续保留。
- 插件测试分别验证普通用户创建目录被拒绝,以及 root 所有目录的原子写入,不放宽生产目录所有者校验。
- IMAP 原始协议测试增加 15 秒读写期限,避免无限等待。
- 新增逐包交叉编译、远程测试执行、systemd 资源隔离及备份恢复工具。逐轮日志单独保存,不覆盖上一轮结果。
### 首轮异常与处置
60 个测试包完成首轮执行,其中插件权限、异平台证书路径断言失败,IMAP 和 SMTP 包超时。现场负载曾达 74,内核记录 11:00:20 发生全机 OOM,MySQL 进程被系统终止后自动重启。首轮没有硬资源或网络隔离,不将此轮描述为无影响验收,也不能仅凭现有证据将全部压力归因于单个测试。
收敛后确认原测试执行器和测试二进制均已结束,Smail、MySQL、Scert 均为运行状态;Smail 主 PID `548293` 未变。没有停止其他项目进程。首轮日志保留在 `/var/tmp/smail-review-full-20260917/results.json` 及各包 `result.log`。
受限启动器第一次因私有临时目录遮住包目录而在测试前失败,补充项目目录映射后正常。IMAP 在 128 MiB cgroup 下首次被局部 OOM 终止,调整测试专属 Go 回收参数后通过,未削弱生产密码算法。192 MiB 尝试因可用内存不足被预检拒绝,没有执行。
### 定向复测
| 范围 | 顶层通过数 | 证据 |
| --- | --- | --- |
| 插件普通用户权限分支 | 17 | `results-20260917-110801/backend-hooks.log`;跳过 Windows 专属策略 |
| 投递队列 | 12 | `results-20260917-110801/backend-services-outbox.log` |
| 证书路径和导入 | 19 | `results-20260917-110801/backend-services-setup-ssl.log` |
| 插件 root 原子写入 | 1 | `smail-plugin-root-review-20260917-1110.service` journal |
| IMAP 全包 | 31 | `results-20260917-111234/backend-listen-imap_server.log` |
| SMTP 全包 | 35 | `results-20260917-111234/backend-listen-smtp_server.log` |
上述相对日志均位于 `/var/tmp/smail-review-full-20260917/`。协议复测使用 128 MiB 硬上限、25% CPU、独立网络及禁止访问生产目录,峰值约 121.1 MiB,用时约 19 秒。SMTP 定向样本两项也在 64 MiB 上限下通过。协议超时在隔离环境中未重现,不据此宣称已定位原超时的唯一原因。
### 备份恢复演练
使用 `/var/backups/smail-before-outbox-20260917-100608/smail.tar.gz`,核对归档 SHA-256 `a2ed56666e9e691a63dfc6734ebe2a1e9c7bf2fe4ceb81cb55028e66ea52d016` 后,在 `/var/tmp/smail-review-restore-20260917-111022/` 恢复。
- 恢复前后双库完整性通过,逐表数量记录在受保护的 `result.json`。
- 副本在独立挂载和网络命名空间启动,128 MiB 硬上限、25% CPU,HTTPS 就绪检查通过。
- 仅连接副本网络空间内部 `127.0.0.1:25`,STARTTLS 后投递一封邮件,确认副本库新增一条,线上库没有该消息。
- 线上服务主 PID 保持不变;演练结束后副本服务已停止,恢复数据和证据保留。
这是同机隔离恢复,不代表异机灾备、恢复时限承诺或最新版本清库安装验收。

View File

@ -0,0 +1,45 @@
param([string]$OutputName = 'full-review-20260917')
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
if ($OutputName -notmatch '^[a-z0-9-]+$') { throw '测试产物目录名称无效' }
$output = Join-Path $root "out\$OutputName"
New-Item -ItemType Directory -Path $output -Force | Out-Null
$previous = @{}
foreach ($name in @('GOOS', 'GOARCH', 'CGO_ENABLED')) {
$previous[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
}
try {
$env:GOOS = 'linux'
$env:GOARCH = 'amd64'
$env:CGO_ENABLED = '0'
Push-Location (Join-Path $root 'backend')
try {
$packages = & go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./...
if ($LASTEXITCODE -ne 0) { throw '无法枚举测试包' }
$manifest = @()
foreach ($package in $packages) {
if (-not $package.Trim()) { continue }
$relative = $package.Substring('git.pcux.cn/xuans/Smail'.Length).TrimStart('/')
$directory = if ($relative) { "backend/$relative" } else { 'backend' }
$target = Join-Path $output $directory
New-Item -ItemType Directory -Path $target -Force | Out-Null
& go test -c -o (Join-Path $target 'review.test') $package
if ($LASTEXITCODE -ne 0) { throw "测试编译失败:$package" }
$manifest += @{ package = $package; directory = $directory }
Write-Output "已编译:$package"
}
$json = ConvertTo-Json -InputObject $manifest -Depth 4
[IO.File]::WriteAllText((Join-Path $output 'manifest.json'), $json, [Text.UTF8Encoding]::new($false))
} finally { Pop-Location }
Copy-Item -LiteralPath (Join-Path $root 'deploy') -Destination (Join-Path $output 'deploy') -Recurse -Force
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'run_review_tests.py') -Destination $output -Force
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'start_review_tests.py') -Destination $output -Force
& tar -czf "$output.tar.gz" -C $output .
if ($LASTEXITCODE -ne 0) { throw '测试产物归档失败' }
Write-Output "仅完成交叉编译,未在本机运行测试:$output.tar.gz"
} finally {
foreach ($name in $previous.Keys) {
[Environment]::SetEnvironmentVariable($name, $previous[$name], 'Process')
}
}

View File

@ -0,0 +1,129 @@
"""在 47 服务器的独立命名空间恢复备份,不替换当前服务或删除文件。"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import sqlite3
import subprocess
import tarfile
import time
def run(*args):
return subprocess.check_output(args, text=True).strip()
def database_counts(root):
counts = {}
for name in ('smail.db', 'smail-state.db'):
with sqlite3.connect(f'file:{root / "data" / name}?mode=ro', uri=True) as database:
assert database.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
tables = database.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").fetchall()
counts[name] = {
table: database.execute('SELECT COUNT(*) FROM "' + table.replace('"', '""') + '"').fetchone()[0]
for (table,) in tables
}
return counts
def main():
parser = argparse.ArgumentParser()
parser.add_argument('backup')
parser.add_argument('--archive-sha', required=True)
args = parser.parse_args()
assert os.geteuid() == 0 and run('hostname') == 'ikun2'
backup = Path(args.backup).resolve(strict=True)
assert backup.parent == Path('/var/backups') and backup.name.startswith('smail-before-')
archive = backup / 'smail.tar.gz'
with archive.open('rb') as stream:
assert hashlib.file_digest(stream, 'sha256').hexdigest() == args.archive_sha, '备份摘要不匹配'
memory = dict(line.split(':', 1) for line in Path('/proc/meminfo').read_text().splitlines())
assert int(memory['MemAvailable'].split()[0]) >= 256 * 1024, '可用内存不足,暂停恢复演练'
stamp = time.strftime('%Y%m%d-%H%M%S')
target = Path('/var/tmp') / ('smail-review-restore-' + stamp)
target.mkdir(mode=0o700)
with tarfile.open(archive) as stream:
for member in stream.getmembers():
path = Path(member.name)
assert not path.is_absolute() and '..' not in path.parts and path.parts[0] == 'smail'
assert member.isdir() or member.isfile(), '备份含特殊文件,停止自动恢复'
subprocess.run(['tar', '-xzf', str(archive), '-C', str(target)], check=True)
restored = target / 'smail'
control = target / 'control'
control.mkdir(mode=0o750)
shutil.chown(control, user='smail', group='smail')
before = database_counts(restored)
live_pid = run('systemctl', 'show', 'smail.service', '--property=MainPID', '--value')
unit = 'smail-restore-review-' + stamp
result = {'backup': str(backup), 'restore': str(target), 'unit': unit, 'before': before}
try:
subprocess.run([
'systemd-run', '--unit=' + unit, '--property=User=smail', '--property=Group=smail',
'--property=PrivateNetwork=yes', '--property=PrivateTmp=yes',
'--property=ProtectSystem=strict', '--property=ProtectHome=yes',
'--property=NoNewPrivileges=yes', '--property=CapabilityBoundingSet=CAP_NET_BIND_SERVICE',
'--property=AmbientCapabilities=CAP_NET_BIND_SERVICE',
'--property=MemoryMax=128M', '--property=MemorySwapMax=0', '--property=CPUQuota=25%',
'--property=TasksMax=128', '--property=RuntimeMaxSec=180s', '--property=OOMPolicy=stop',
'--property=KillMode=control-group', '--property=TimeoutStopSec=10s',
'--property=BindPaths=' + str(restored) + ':/opt/smail ' + str(control) + ':/run/smail',
'--property=ReadWritePaths=/opt/smail /run/smail',
'--property=WorkingDirectory=/opt/smail', '--setenv=SMAIL_ROOT=/opt/smail',
'--setenv=GOMEMLIMIT=96MiB', '--setenv=GOMAXPROCS=1',
'/opt/smail/smail',
], check=True)
ready = False
for _ in range(60):
pid = run('systemctl', 'show', unit, '--property=MainPID', '--value')
if pid != '0':
probe = subprocess.run([
'nsenter', '--target', pid, '--net', 'curl', '--silent', '--fail', '--max-time', '2',
'--noproxy', '*', '--cacert', str(restored / 'ssl/custom.crt'),
'--resolve', 'mail.solit.cn:443:127.0.0.1', 'https://mail.solit.cn/api/ping',
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if probe.returncode == 0:
ready = True
break
time.sleep(1)
assert ready, '恢复服务未就绪'
assert os.readlink('/proc/' + pid + '/ns/net') != os.readlink('/proc/' + live_pid + '/ns/net')
assert os.stat('/proc/' + pid + '/root/opt/smail/data/smail.db').st_ino == (restored / 'data/smail.db').stat().st_ino
assert (restored / 'data/smail.db').stat().st_ino != Path('/opt/smail/data/smail.db').stat().st_ino
probe = r'''
import smtplib,ssl,sys
from email.message import EmailMessage
message=EmailMessage()
message['From']='restore@review.invalid'
message['To']='remotecheck@solit.cn'
message['Subject']='restore-review'
message['Message-ID']='<restore-'+sys.argv[2]+'@review.invalid>'
message.set_content('restore database write check')
context=ssl.create_default_context(cafile=sys.argv[1])
with smtplib.SMTP('127.0.0.1',25,timeout=20) as client:
client.ehlo('restore.invalid')
client._host='mail.solit.cn'
client.starttls(context=context)
client.ehlo('restore.invalid')
assert not client.send_message(message)
'''
subprocess.run(['nsenter', '--target', pid, '--net', 'python3', '-c', probe,
str(restored / 'ssl/custom.crt'), stamp], check=True)
with sqlite3.connect(f'file:{restored / "data/smail.db"}?mode=ro', uri=True) as database:
assert database.execute('SELECT COUNT(*) FROM email WHERE msg_id=?', ('restore-' + stamp + '@review.invalid',)).fetchone()[0] == 1
with sqlite3.connect('file:/opt/smail/data/smail.db?mode=ro', uri=True) as database:
assert database.execute('SELECT COUNT(*) FROM email WHERE msg_id=?', ('restore-' + stamp + '@review.invalid',)).fetchone()[0] == 0
result.update(ready=True, isolated=True, restored_smtp=True, live_pid_unchanged=live_pid == run(
'systemctl', 'show', 'smail.service', '--property=MainPID', '--value'))
finally:
subprocess.run(['systemctl', 'stop', unit], check=True)
result['after'] = database_counts(restored)
result['service_log'] = run('journalctl', '-u', unit, '--no-pager', '-n', '80')
(target / 'result.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps({key: value for key, value in result.items() if key not in ('before', 'after', 'service_log')}, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()

View File

@ -0,0 +1,65 @@
"""在远程独立目录逐包执行测试,保留全部日志和跳过记录。"""
import argparse
import json
import os
from pathlib import Path
import signal
import subprocess
import time
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--only', action='append', default=[])
parser.add_argument('--run', default='')
args = parser.parse_args()
root = Path(__file__).resolve().parent
assert root.parent == Path('/var/tmp') and root.name.startswith('smail-review-')
assert os.geteuid() != 0, '禁止使用 root 执行测试'
environment = os.environ.copy()
for name in list(environment):
if name.startswith('SMAIL_'):
environment.pop(name)
environment['GOMAXPROCS'] = '1'
environment['GOMEMLIMIT'] = '64MiB'
environment['GOGC'] = '20'
output = root / ('results-' + time.strftime('%Y%m%d-%H%M%S'))
output.mkdir(mode=0o700)
results = []
for item in json.loads((root / 'manifest.json').read_text(encoding='utf-8')):
if args.only and item['directory'] not in args.only:
continue
directory = (root / item['directory']).resolve()
assert directory.is_relative_to(root)
binary = directory / 'review.test'
with binary.open('rb') as stream:
assert stream.read(5) == b'\x7fELF\x02'
started = time.monotonic()
log = output / (item['directory'].replace('/', '-') + '.log')
with log.open('w', encoding='utf-8') as stream:
command = [str(binary), '-test.v', '-test.timeout=180s']
if args.run:
command.append('-test.run=' + args.run)
process = subprocess.Popen(command, cwd=directory, env=environment,
stdout=stream, stderr=subprocess.STDOUT, start_new_session=True)
try:
code = process.wait(timeout=195)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
code = 124
lines = log.read_text(encoding='utf-8', errors='replace').splitlines()
result = dict(item, code=code, log=str(log), seconds=round(time.monotonic() - started, 2),
passed=sum(line.startswith('--- PASS:') for line in lines),
failed=[line for line in lines if line.lstrip().startswith('--- FAIL:')],
skipped=[line for line in lines if line.lstrip().startswith('--- SKIP:')])
results.append(result)
print(json.dumps(result, ensure_ascii=False), flush=True)
(output / 'results.json').write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding='utf-8')
print('测试记录:' + str(output), flush=True)
return int(any(item['code'] != 0 for item in results))
if __name__ == '__main__':
raise SystemExit(main())

View File

@ -0,0 +1,47 @@
"""通过受限 systemd 临时服务执行远程测试,不复用生产服务进程。"""
import argparse
import os
from pathlib import Path
import subprocess
import time
def main():
parser = argparse.ArgumentParser()
parser.add_argument('directory')
parser.add_argument('--only', action='append', default=[])
parser.add_argument('--run', default='')
parser.add_argument('--memory-mib', type=int, choices=(64, 96, 128, 192), default=192)
args = parser.parse_args()
root = Path(args.directory).resolve(strict=True)
assert os.geteuid() == 0
assert root.parent == Path('/var/tmp') and root.name.startswith('smail-review-')
memory = dict(line.split(':', 1) for line in Path('/proc/meminfo').read_text().splitlines())
assert int(memory['MemAvailable'].split()[0]) >= (args.memory_mib + 128) * 1024, '可用内存不足,停止增加测试负载'
unit = 'smail-tests-' + time.strftime('%Y%m%d-%H%M%S')
command = [
'systemd-run', '--wait', '--pipe', '--unit=' + unit,
'--property=User=smail', '--property=Group=smail',
'--property=MemoryMax=' + str(args.memory_mib) + 'M', '--property=MemorySwapMax=0',
'--property=TasksMax=128', '--property=CPUQuota=25%',
'--property=RuntimeMaxSec=1800s', '--property=TimeoutStopSec=10s',
'--property=KillMode=control-group', '--property=OOMPolicy=stop',
'--property=PrivateTmp=yes', '--property=PrivateNetwork=yes',
'--property=BindPaths=' + str(root),
'--property=ProtectSystem=strict', '--property=ProtectHome=yes',
'--property=InaccessiblePaths=/opt/smail /run/smail',
'--property=NoNewPrivileges=yes', '--property=ReadWritePaths=' + str(root),
'--property=WorkingDirectory=' + str(root),
'python3', str(root / 'run_review_tests.py'),
]
for directory in args.only:
command.extend(['--only', directory])
if args.run:
command.extend(['--run', args.run])
print('测试服务:' + unit, flush=True)
return subprocess.run(command).returncode
if __name__ == '__main__':
raise SystemExit(main())