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