diff --git a/backend/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go index 7efec67..25ec65f 100644 --- a/backend/listen/http_server/http_server.go +++ b/backend/listen/http_server/http_server.go @@ -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 } diff --git a/backend/listen/http_server/https_server.go b/backend/listen/http_server/https_server.go index a70179e..267257e 100644 --- a/backend/listen/http_server/https_server.go +++ b/backend/listen/http_server/https_server.go @@ -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 } diff --git a/backend/listen/http_server/shutdown.go b/backend/listen/http_server/shutdown.go new file mode 100644 index 0000000..33d6eb4 --- /dev/null +++ b/backend/listen/http_server/shutdown.go @@ -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 +} diff --git a/backend/listen/http_server/shutdown_test.go b/backend/listen/http_server/shutdown_test.go new file mode 100644 index 0000000..b1f7816 --- /dev/null +++ b/backend/listen/http_server/shutdown_test.go @@ -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") + } + }) + } +} diff --git a/docs/数据库迁移重启补充验收.md b/docs/数据库迁移重启补充验收.md new file mode 100644 index 0000000..b3b5a83 --- /dev/null +++ b/docs/数据库迁移重启补充验收.md @@ -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`,不能据此把全项目扫描标为通过。 diff --git a/docs/生产补充验收记录.md b/docs/生产补充验收记录.md index 832948c..0ae0f11 100644 --- a/docs/生产补充验收记录.md +++ b/docs/生产补充验收记录.md @@ -300,3 +300,11 @@ Windows 受保护本地通道验收需要提权会话,不得降级成公开 TC 本轮整理明确列出的应用整改、回归测试、验收工具、持续集成和本记录;原有 `docs/云端全项目测试报告.md` 的用户修改不编辑、不加入本轮提交。 本轮不执行生产版本替换,测试候选与正式实例保持分离。未关闭门禁继续保留,不能将本轮提交或推送成功解释为生产验收通过。 + +### 9 月 21 日数据库迁移与重启补充 + +详见 `数据库迁移重启补充验收.md`。真实云端迁移发现后台重启可能提前关闭 HTTP/HTTPS 响应,现改为有界排空后停服。本地确定性反例、五轮竞态回归和云端六项零跳过专项共同复核修复。 + +SQLite 已完成迁移失败不覆盖目标、成功迁移、内部重启、系统级进程重启、目标不可读时明确失败不回退、恢复后真实收发与历史配置保留。最新主程序 `21ec0820f9b09eb1f8262d6d444bbc5d235d4cf64115d6f4b3378be2083533a0` 的 MySQL/PostgreSQL 基础回归通过,业务与配置历史同库、SQLite 文件零个;尚不能扩大为 SQL 切库进程重启验收通过。 + +失败反例和离线源库均保留,正式服务与其他项目未变。SQL 迁移恢复、迁移中断、浏览器、外部服务、安全告警及凭据轮换等仍有未关闭门禁。 diff --git a/docs/静态安全告警复核记录.md b/docs/静态安全告警复核记录.md index e63c8ee..2d1b099 100644 --- a/docs/静态安全告警复核记录.md +++ b/docs/静态安全告警复核记录.md @@ -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` 上管理员恢复整改后的候选。前一项只增加测试,后一项包含应用整改;均未替换正式程序。