修复重启确认响应中断并补齐迁移恢复实机验收
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-21 05:28:46 +08:00
parent f589f10f05
commit 375ffcabfe
7 changed files with 243 additions and 4 deletions

View File

@ -27,8 +27,8 @@ func HttpStop() {
httpServerMu.Lock()
defer httpServerMu.Unlock()
if httpServer != nil {
if err := httpServer.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.WithError(err).Warn("close HTTP server failed")
if err := shutdownWebServer(httpServer, webShutdownTimeout); err != nil {
log.WithError(err).Warn("HTTP 请求未能在停服期限内排空,已强制关闭连接")
}
httpServer = nil
}

View File

@ -127,8 +127,8 @@ func HttpsStop() {
httpsServerMu.Lock()
defer httpsServerMu.Unlock()
if httpsServer != nil {
if err := httpsServer.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.WithError(err).Warn("close HTTPS server failed")
if err := shutdownWebServer(httpsServer, webShutdownTimeout); err != nil {
log.WithError(err).Warn("HTTPS 请求未能在停服期限内排空,已强制关闭连接")
}
httpsServer = nil
}

View File

@ -0,0 +1,23 @@
package http_server
import (
"context"
"errors"
"net/http"
"time"
)
const webShutdownTimeout = 10 * time.Second
// 先停止监听并等待在途响应及会话提交,超时才强制断开,避免重启接口丢失确认响应。
func shutdownWebServer(server *http.Server, timeout time.Duration) error {
if server == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
return errors.Join(err, server.Close())
}
return nil
}

View File

@ -0,0 +1,123 @@
package http_server
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func TestWebShutdownTimeoutClosesActiveConnection(t *testing.T) {
entered, canceled, release := make(chan struct{}), make(chan struct{}), make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
close(entered)
select {
case <-r.Context().Done():
close(canceled)
case <-release:
}
}))
t.Cleanup(func() { close(release); server.Close() })
client := server.Client()
client.Timeout = 3 * time.Second
requestDone := make(chan error, 1)
go func() {
result, err := client.Post(server.URL, "application/json", strings.NewReader("{}"))
if result != nil {
_ = result.Body.Close()
}
requestDone <- err
}()
select {
case <-entered:
case <-time.After(3 * time.Second):
t.Fatal("request did not enter handler")
}
if err := shutdownWebServer(server.Config, 25*time.Millisecond); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected shutdown deadline error, got %v", err)
}
select {
case <-canceled:
case <-time.After(3 * time.Second):
t.Fatal("shutdown timeout did not close active connection")
}
if err := <-requestDone; err == nil {
t.Fatal("timed-out request unexpectedly succeeded")
}
}
func TestWebShutdownNilServer(t *testing.T) {
if err := shutdownWebServer(nil, time.Second); err != nil {
t.Fatal(err)
}
}
// 真实连接仍在处理响应时停止服务,不能通过提前关闭客户端伪装成重启成功。
func TestWebStopPreservesActiveResponse(t *testing.T) {
for _, secure := range []bool{false, true} {
t.Run(fmt.Sprintf("tls=%v", secure), func(t *testing.T) {
entered, release := make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
finish := func() { releaseOnce.Do(func() { close(release) }) }
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(entered)
<-release
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"status":"accepted"}`)
}))
var stop func()
if secure {
server.StartTLS()
httpsServer, stop = server.Config, HttpsStop
} else {
server.Start()
httpServer, stop = server.Config, HttpStop
}
t.Cleanup(func() { finish(); stop(); server.Close() })
client := server.Client()
client.Timeout = 3 * time.Second
response := make(chan error, 1)
go func() {
result, err := client.Post(server.URL, "application/json", strings.NewReader("{}"))
if err != nil {
response <- err
return
}
defer result.Body.Close()
body, err := io.ReadAll(result.Body)
if err == nil && (result.StatusCode != http.StatusOK || string(body) != `{"status":"accepted"}`) {
err = fmt.Errorf("unexpected response: status=%d body=%q", result.StatusCode, body)
}
response <- err
}()
select {
case <-entered:
case <-time.After(3 * time.Second):
t.Fatal("request did not enter handler")
}
stopped := make(chan struct{})
go func() { stop(); close(stopped) }()
select {
case <-stopped:
t.Error("server stop returned before active response was completed")
case <-time.After(75 * time.Millisecond):
}
finish()
if err := <-response; err != nil {
t.Errorf("active response was lost: %v", err)
}
select {
case <-stopped:
case <-time.After(3 * time.Second):
t.Fatal("server did not stop after response completed")
}
})
}
}

View File

@ -0,0 +1,79 @@
# 数据库迁移重启补充验收
## 结论与范围
北京时间 2026 年 9 月 21 日,使用隔离云端实例补充 SQLite 迁移、重启及目标库故障恢复验收,并修复真实发现的 HTTP/HTTPS 重启确认响应丢失问题。本轮未替换 `/opt/smail` 的正式程序,未清空正式数据库,未停止其他项目或共享数据库服务。
- [✓] SQLite 迁移失败不覆盖目标,失败后解除写维护状态。
- [✓] SQLite 迁移后内部重启、系统级进程重启及目标不可读后的恢复。
- [✓] HTTP/HTTPS 活动响应排空修复与本地、Linux 云端专项复核。
- [✓] 最新候选 SQLite/MySQL/PostgreSQL 空数据基础回归,配置历史与业务同库。
- [ ] MySQL/PostgreSQL 切库后的真实进程重启和故障恢复。
- [ ] 迁移复制、提交途中进程中断后的恢复。
- [!] 浏览器、外部收件、自动续期、安全告警、凭据轮换及其他生产门禁仍未关闭。
上述通过项不代表整个项目审计结束,也不是正式实例升级或生产放行。
## 重启响应缺陷与修复
重启接口交付信号后,主循环立即停止服务。原 HTTP/HTTPS 停服直接调用 `http.Server.Close()`,可能在响应及会话中间件完成前关闭连接。云端收到 `RemoteDisconnected`,同时服务日志确认重启请求已经被处理;不能把这种失败按偶发网络问题忽略。
新增统一停服方法:先调用 `Shutdown` 等待在途响应,期限为 10 秒;超时才强制关闭并保留错误。两种 Web 监听都使用此方法,没有使用任意等待或放宽客户端验收标准。
| 检查 | 证据与结果 |
| --- | --- |
| 修复前确定性反例 | `run-2026-09-20T21-08-10-337Z-39880`;HTTP、HTTPS 均提前关闭,客户端 EOF |
| 修复后五轮竞态回归 | `run-2026-09-20T21-12-58-775Z-4492`;通过 |
| 五包回归 | `run-2026-09-20T21-13-36-986Z-3400`;HTTP、控制器、重启、数据库迁移、初始化包通过;不宣称可选集成全部零跳过 |
| Linux 云端专项 | `smail-review-ssl-apex-20260921/20260921-051655-http-restart`;六个顶层测试通过、零跳过、退出零 |
| 验收器单元测试 | `test_runtime*.py` 共 42 项通过 |
`run-2026-09-20T21-09-33-980Z-37428` 保留首次超时测试夹具失败:请求体未消费且缺少独立释放路径,测试服务器清理等待活动请求。确认 PID 后仅停止本轮测试进程;修正夹具后另跑通过,不将夹具错误描述为应用漏洞。
日志名中的 `2026-09-20T21...Z` 为 UTC,对应北京时间 9 月 21 日。
## SQLite 实机证据
最终实例:`/var/tmp/smail-review-runtime-20260921-051723/`。
本地归档:`test-log/production-remediation-20260921/migration-runtime-evidence/smail-review-runtime-20260921-051723/`。
1. 从空库初始化、写入配置历史,真实 SMTP 收信后由 IMAP/POP3 读取中文正文和附件。
2. 迁移预览后故意占用目标,执行返回 HTTP 409、`DATABASE_MIGRATION_TARGET_EXISTS`;记录为 `failed / TARGET_EXISTS`,目标内容未被覆盖。随后配置写入成功,证明维护锁已释放。
3. 成功迁移至 `migrated.db`,配置代次推进至 4;等待重启期间业务写入返回 HTTP 503、`DATABASE_RESTART_REQUIRED`。
4. API 内部重启返回 `accepted`,旧 Cookie 会话仍有效;PID 和 InvocationID 不变,这不是进程替换。
5. `systemctl restart` 后 PID 和 InvocationID 改变,文件描述符证实仅打开目标业务库,不打开源业务库。
6. 将本轮目标文件权限改为 `000` 后再次启动,进程退出码为 1、MainPID 为 0、状态为 failed;配置仍指向目标库,没有静默回退旧库。
7. 在收尾保障路径恢复目标权限为 `0600`,重新启动成功。内部重启、系统重启、故障恢复三阶段均验证登录、三种协议认证、旧邮件及新邮件正文和附件。
8. 新邮件仅写入目标库。源库完整逻辑摘要未变,源配置历史一条、目标一条,`kind/id/payload/digest` 完整一致。
进程证据:内部重启前后 `773001`,系统重启后 `773123`,故障恢复后 `773224`。
只有 `migrated.db` 是活动业务数据库;`smail.db` 和故意占用的 `occupied.db` 保留为离线证据,不是额外运行数据库,未经许可不删除。
保留两轮云端失败:`045727` 为验收器未先处理基础回归留下的待重启配置,被正确拒绝;`050341` 为真实重启响应中断,促成本次应用修复。失败记录均未删除或改成通过。
## 最新候选的 SQL 基础回归
| 数据库 | 隔离实例时间后缀 | 结果 |
| --- | --- | --- |
| MySQL | `20260921-052137` | 退出零、基础回归通过、配置历史一条、SQLite 文件零个 |
| PostgreSQL | `20260921-052322` | 退出零、基础回归通过、配置历史一条、SQLite 文件零个 |
本地证据位于 `restart-runtime-evidence/`,摘要清单为 `restart-evidence-index.json`,均在 `test-log/production-remediation-20260921/` 下。逐一复核四份下载文件摘要,账号已锁定、数据库桥接已停止,OOM、资源采集错误和清理错误均为零。此处仅为 SQL 基础回归,不包含 SQL 切库进程重启。
最后重新读取六个保护服务状态,PID、InvocationID 与回归前一致,无运行中的 Smail 验收单元。正式程序 SHA-256 仍为 `c14ef1c5ba1dc2096c312c8357039a7330c985cbeac90765aed01d8151e05e1f`。
## 构建与可追溯性
版本 `review-restart-20260921`,构建源码标识 `f589f10f0556daeb9437673322d904d17e0a2df2-dirty`。`restart-source-manifest.json` 记录 610 个后端 Go 文件的摘要,`restart-build-manifest.json` 记录候选产物。
| 产物 | SHA-256 |
| --- | --- |
| `smail-restart` | `21ec0820f9b09eb1f8262d6d444bbc5d235d4cf64115d6f4b3378be2083533a0` |
| `smail-admin-restart` | `73330cd069570b7bf0181db5497465faad3897eb9b564d3045774bb35bc1472f` |
| `http-restart.test` | `d09687338b3f5ded2ff034587292974c975c16e2a09b0595435622125da517ee` |
本阶段重新编译后端,复用前轮未改动的前端资源和三个 Linux 插件,不声称又完成一次前端重建。拟用的 PowerShell 构建脚本被执行策略拒绝,没有更改策略或执行该脚本,随后使用原生 Go 构建命令;保留的脚本不是实际完整构建成功证据。
静态扫描仅复扫变更的 HTTP 包:九个文件、2,035 行、零告警、零加载错误、退出零,未增加抑制。之前全项目仍有 38 条告警,详见 `静态安全告警复核记录.md`,不能据此把全项目扫描标为通过。

View File

@ -300,3 +300,11 @@ Windows 受保护本地通道验收需要提权会话,不得降级成公开 TC
本轮整理明确列出的应用整改、回归测试、验收工具、持续集成和本记录;原有 `docs/云端全项目测试报告.md` 的用户修改不编辑、不加入本轮提交。
本轮不执行生产版本替换,测试候选与正式实例保持分离。未关闭门禁继续保留,不能将本轮提交或推送成功解释为生产验收通过。
### 9 月 21 日数据库迁移与重启补充
详见 `数据库迁移重启补充验收.md`。真实云端迁移发现后台重启可能提前关闭 HTTP/HTTPS 响应,现改为有界排空后停服。本地确定性反例、五轮竞态回归和云端六项零跳过专项共同复核修复。
SQLite 已完成迁移失败不覆盖目标、成功迁移、内部重启、系统级进程重启、目标不可读时明确失败不回退、恢复后真实收发与历史配置保留。最新主程序 `21ec0820f9b09eb1f8262d6d444bbc5d235d4cf64115d6f4b3378be2083533a0` 的 MySQL/PostgreSQL 基础回归通过,业务与配置历史同库、SQLite 文件零个;尚不能扩大为 SQL 切库进程重启验收通过。
失败反例和离线源库均保留,正式服务与其他项目未变。SQL 迁移恢复、迁移中断、浏览器、外部服务、安全告警及凭据轮换等仍有未关闭门禁。

View File

@ -1,5 +1,11 @@
# 静态安全告警复核记录
## 9 月 21 日重启排空修复包复扫
在 `f589f10` 后端候选上修改 HTTP/HTTPS 停服逻辑后,仅复扫 `./listen/http_server`:九个文件、2,035 行、告警零、包加载错误零、退出码零、`nosec=0`。这是本机针对 Linux 目标平台的变更包扫描,不是全项目再次扫描,也不是把全部历史告警清零。
证据:`test-log/production-remediation-20260921/gosec-restart-http.json` 及同名日志。JSON SHA-256 为 `ec180df1a23d37ff6817c1013e5248bf1cbc231b418e4e6f3f403ea75747a4cd`。此前全项目扫描仍为 38 条、退出码 1,剩余复核及历史凭据撤销门禁保持未完成。应用修复和实机证据见 `数据库迁移重启补充验收.md`。
## 结论与范围
本记录保留应用候选 `8899e74`、验收工具基线 `ddc9652` 的历史扫描,并补充基线 `9e5496d` 上管理员恢复整改后的候选。前一项只增加测试,后一项包含应用整改;均未替换正式程序。