diff --git a/backend/internal/deployment/ssh.go b/backend/internal/deployment/ssh.go index 36a40ed..52136d0 100644 --- a/backend/internal/deployment/ssh.go +++ b/backend/internal/deployment/ssh.go @@ -11,6 +11,7 @@ import ( "os" "path" "strings" + "sync" "sync/atomic" "time" @@ -213,6 +214,8 @@ type sshClientAdapter struct { ssh *ssh.Client sftp *sftp.Client disconnected atomic.Bool + closeOnce sync.Once + closeErr error } // sshOperation 取消时关闭传输并等待操作退出,避免回滚与仍在写入的操作并行。 @@ -450,18 +453,24 @@ func (c *sshClientAdapter) runCommand(command []string) (CommandResult, error) { } func (c *sshClientAdapter) Close() error { - var first error - // 先关闭传输,避免无响应的 SFTP 服务阻塞关闭过程。 - if c.ssh != nil { - c.disconnected.Store(true) - first = c.ssh.Close() - } - if c.sftp != nil { - if err := c.sftp.Close(); err != nil && first == nil { - first = err + c.closeOnce.Do(func() { + wasDisconnected := c.disconnected.Swap(true) + // 先关闭传输,避免无响应的 SFTP 服务阻塞关闭过程。 + if c.ssh != nil { + c.closeErr = c.ssh.Close() } - } - return first + if c.sftp != nil { + if err := c.sftp.Close(); err != nil && c.closeErr == nil { + // 正常主动关闭传输后,SFTP 收尾可能返回 EOF 或连接已关闭。 + // 仅消除本次主动关闭的预期错误,保留既有断连及其他关闭失败。 + if c.ssh != nil && !wasDisconnected && (errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed)) { + return + } + c.closeErr = err + } + } + }) + return c.closeErr } func validateCommand(command []string) error { diff --git a/backend/internal/deployment/ssh_close_test.go b/backend/internal/deployment/ssh_close_test.go new file mode 100644 index 0000000..d73b2ca --- /dev/null +++ b/backend/internal/deployment/ssh_close_test.go @@ -0,0 +1,65 @@ +package deployment + +import ( + "sync" + "testing" + + "github.com/pkg/sftp" +) + +func TestSSHCloseHealthyTransportDoesNotReportSFTPShutdownEOF(t *testing.T) { + factory, server := safetyFactory(t, requestSFTPHandler(sftp.InMemHandler(), false, false)) + client, err := factory.Connect(t.Context(), server) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + if err := client.MkdirAll(t.Context(), "/close-check"); err != nil { + t.Fatal(err) + } + if err := client.Close(); err != nil { + t.Fatalf("正常关闭连接不得把本机主动关闭产生的错误当作部署失败:%v", err) + } + if err := client.Close(); err != nil { + t.Fatalf("重复关闭应返回相同的成功结果:%v", err) + } +} + +func TestSSHCloseConcurrentCallsShareResult(t *testing.T) { + factory, server := safetyFactory(t, requestSFTPHandler(sftp.InMemHandler(), false, false)) + client, err := factory.Connect(t.Context(), server) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + var calls sync.WaitGroup + for range 8 { + calls.Go(func() { + if err := client.Close(); err != nil { + t.Errorf("并发关闭不得制造伪失败:%v", err) + } + }) + } + calls.Wait() +} + +func TestSSHClosePreservesPriorTransportFailure(t *testing.T) { + factory, server := safetyFactory(t, requestSFTPHandler(sftp.InMemHandler(), false, false)) + client, err := factory.Connect(t.Context(), server) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + adapter := client.(*sshClientAdapter) + adapter.disconnected.Store(true) + if err := adapter.ssh.Close(); err != nil { + t.Fatal(err) + } + first := client.Close() + if first == nil { + t.Fatal("既有传输失败不能被正常关闭分支吞掉") + } + if second := client.Close(); second != first { + t.Fatalf("重复关闭必须保留首次失败:%v / %v", first, second) + } +} diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 0909b79..7bfbc91 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,30 @@ # Linux 生产流程测试记录 +## 2026年10月3日23:14 真实中断恢复与关闭误判 + +### 重载阶段中断与自动恢复 + +- 第一轮任务 `b795be80-e086-46b4-a2ba-e7b51c8662f0` 因旧 systemd 不支持 `--kill-whom`,注入失败并正常完成;不能计为崩溃测试通过。确认本机帮助后改用 `--kill-who=main`。 +- 22:57:25 对正式 Scert 主进程发送 SIGKILL,命中任务 `c68e3956-1b6d-42c7-ba9a-753ef2d1a44b` 第二目标事务的 reload_attempted 阶段。 +- systemd 的 MainPID 从 130794 变为 134595,NRestarts=1;23:58 前已核实服务恢复 active、回环严格 TLS 就绪检查通过。此处实际核查时间为 22:58:16。 +- 事务 `20261003-145716.121859533-09a2a331489a` 与 `20261003-145720.641188477-c769c8d3e153` 均 rolled_back;任务为 failed/service_restart,没有覆盖为成功。 +- 双机 cert/fullchain 摘要保持 `7e8b56227b345ed9b66659e428f9bd4421604f11253ab88d65b3ed61737f35cf`,未发现该域名遗留事务文件。第二台正常 DNS HTTPS 200、peer=45.136.13.241、TLS 校验结果零。 + +### 真实待恢复事务及人工提交异常 + +- 任务 `5e89aa6d-3e25-4e3b-bf6c-71bf612aaf32` 在第二台 reload_attempted 阶段再次中断;临时 OUTPUT 拒绝规则仅匹配第一台 Scert 用户 UID 997 到第二台 TCP 22,预设九十秒撤销定时器。 +- 第一目标自动回滚;第二事务 `20261003-150034.784155038-30bcd090470c` 因 SSH 被拒进入 recovery_required。确认服务已恢复后立即撤销规则和定时器;未影响 root SSH 或第二台 HTTPS,未创建辅助脚本。 +- 正式本机管理菜单三选择完成提交,完整确认后生成处置 `bc4099fd53c10ce0ae643ae2a89c2597fa48ed7ea6d6661fe4126f7f310b7007`,执行时间 23:02:05—23:02:34。 +- 目标文件及备份摘要匹配,锁归属匹配;动作后原事务 phase=commit_verified、status=recovery_required,远端备份及锁均已不存在,处置却为 uncertain,持久化占用保留。这是失败,不算人工提交通过。 +- 原无可信旧基线的两条事务未改写。新不确定处置不能重放或直接删占用;第二台站点保持严格 HTTPS 200,但后续新部署被占用阻止。 + +### 修复及验证边界 + +- 新增真实 SSH/SFTP 协议用例在第一台 Linux 复现健康 Close 返回 use of closed network connection,CLOSE_REPRO_EXIT=1。 +- 修复正常主动关闭后的预期错误分类及重复、并发关闭幂等,保留已存在的断连和其他关闭错误。`ssh-close-race.log` 中部署整包竞态通过,CLOSE_RACE_EXIT=0,约 3.261 秒。 +- 后端全量及静态检查仍在同一 SSH 会话执行,正式候选包尚未更新。该修复不追溯改写 uncertain 历史记录。 +- 本轮为保留数据、同一有效证书重新部署,不是清库首次 Web、不同版本证书回滚、ACME 新签发或完整生产验收。 + ## 2026年10月3日22:44 多对多、恢复负向与外部门禁 ### 正确域名的第二组双机推送 diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 9775017..517991a 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -1,5 +1,14 @@ # Scert 开发任务清单 +## 2026年10月3日23:14 真实故障整改进度 + +- [✓] 正式包真实进程中断后双机自动回滚,任务保留重启失败历史,第二台网站继续通过严格 HTTPS。 +- [!] 实机人工提交发现连接关闭误判:远端收尾完成,但处置为 uncertain,第二台仍有持久化部署占用。 +- [✓] 原生 Linux 真实 SSH/SFTP 新增回归先复现失败;不是仅根据源码推断。 +- [x] 主动关闭错误分类、重复及并发关闭幂等已修复;部署整包竞态通过,既有关闭故障负向未放宽。 +- [~] 后端全量、静态检查、干净候选包及实机更新进行中。 +- [ ] 未确定处置需保留原记录并通过独立授权重新核查收尾,不能直接删占用或重放动作;人工成功场景及其余上线门禁继续待验。 + ## 2026年10月3日22:44 最新生产验证进度 - [✓] 两个有效域名各绑定同两台服务器,正式部署及导入任务取得四个目标成功、四条 committed 的直接证据;第二台两域名正常 DNS 严格 HTTPS 200。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 78e6e23..887af22 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,14 @@ # Scert 整改任务执行清单 +## 2026年10月3日23:14 当前整改任务 + +- [✓] 真实部署重载阶段 SIGKILL 后,systemd 自动重启及双机回滚通过;第二台 HTTPS 正常。 +- [✓] 故障注入仅限制 Scert 用户到第二台 SSH,未改变 root 管理连接及网站端口;临时规则和撤销定时器已撤销。 +- [!] 人工提交到达 commit_verified 并清理远端锁后,被连接关闭错误误判为 uncertain;原记录及持久化占用保留,不手动修正终态。 +- [x] 健康 SSH/SFTP 关闭错误已通过真实协议测试复现并修复,整包竞态退出码零。 +- [~] 后端全量、静态检查及正式包更新;完成后补实机验证。 +- [ ] 独立授权的未确定处置重新核查收尾、人工回滚成功、执行中断连查询与其他生产门禁未完成。 + ## 2026年10月3日22:44 当前执行结论 - [✓] 多对多正常路径取得直接证据:两个有效域名、两台服务器、四目标成功,第二域名通过正式导入接口接入,未直接改数据库。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index e3347d9..26bae34 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,17 @@ # Scert 整改、合并与优化清单 +## 2026年10月3日23:14 崩溃恢复实测与关闭误判修复 + +- [✓] 正式 API 任务 `c68e3956-1b6d-42c7-ba9a-753ef2d1a44b` 在第二目标重载阶段被真实 SIGKILL 中断;systemd 自动重启,两条事务均 rolled_back,任务保留 service_restart 失败记录。 +- [✓] 双机证书摘要一致,事务临时文件、备份和锁由产品恢复链路清理;第二台正常 DNS 严格 HTTPS 200。该证据仅覆盖同一有效证书重新部署,不代表不同版本证书回滚。 +- [!] 短暂限制 Scert 用户到第二台 SSH 后形成真实待恢复事务。正式人工提交完成文件及锁收尾,却因连接关闭误判持久化 uncertain,持久化占用仍阻止第二台的新部署;网站仍正常。临时网络限制及撤销定时器已撤销。 +- [✓] Linux 真实 SSH/SFTP 协议回归复现健康连接关闭返回连接已关闭错误,修复前退出码一;不是内存远端替身。 +- [x] 已修正主动关闭产生的预期 SFTP 错误分类,并使重复和并发关闭幂等;既有断连及其他关闭错误仍返回失败。部署整包竞态通过。 +- [~] 正在执行 Linux 后端全量与静态检查;随后构建干净候选包并安装。现有 uncertain 记录和占用不直接改写,需独立的受保护重新核查收尾流程,不能自动重放。 +- [ ] 人工提交及回滚成功、执行中断连查询、未确定处置安全收尾、三数据库首次 Web、系统邮件、DNS/ACME 全矩阵与公网验收尚未完成。 + +第一台公网备案拦截和 Smail 系统通知授权缺失仍是门禁;未开放额外端口、关闭 TLS 验证或创建辅助验收脚本。 + ## 2026年10月3日22:44 多对多推送通过与公网门禁复核 本节为最新结论;仅 Linux,整体生产验收未通过。上批的失败事务继续保留,不用后续成功覆盖失败记录。 diff --git a/doc/部署事务人工处置设计.md b/doc/部署事务人工处置设计.md index cf5b0d6..779bbb3 100644 --- a/doc/部署事务人工处置设计.md +++ b/doc/部署事务人工处置设计.md @@ -1,5 +1,16 @@ # 部署事务人工处置设计 +## 2026年10月3日23:14 实机暴露的关闭与重新核查边界 + +真实人工提交已完成文件核验、备份删除、commit_verified 持久化和远端解锁,但 SSH 先主动关闭、SFTP 后关闭返回预期连接已关闭错误,导致执行器报告 uncertain。新增 Linux 真实协议回归已复现,修复限制为正常主动关闭后的 EOF 或连接已关闭,不吞掉既有断连、其他关闭错误或动作错误。重复和并发 Close 共享首次结果。 + +历史处置 `bc4099fd53c10ce0ae643ae2a89c2597fa48ed7ea6d6661fe4126f7f310b7007` 保持 uncertain,其占用继续阻止同服务器部署。不能因为修复了代码、当前站点正常或历史阶段为 commit_verified 就直接改写该处置、原事务或删除占用。 + +- [ ] 后续需要独立、明确授权的重新核查收尾流程,保留原不确定结果,并重新核验身份、历史证据、全量文件、临时文件、备份、锁与严格 HTTPS 后记录新审计。 +- [ ] 该流程不能把超时视作执行者已停止,不能自动重放原动作或静默选择相反动作;同样需要并发、重启和审计失败验证。 + +本节记录新发现的缺口,不代表该收尾能力已经实现;已有三动作的成功实机验收仍未通过。 + ## 2026年10月3日22:44 无可信基线的实机拒绝 正式本机终端对有事务记录但 `previous_fingerprint` 为空的真实失败事务执行恢复基线请求,持久处置结果为 rejected。确认动作没有放宽执行器前置要求,目标文件和锁没有因该请求被变更,原事务仍为 recovery_required。 diff --git a/doc/部署事务人工核查任务清单.md b/doc/部署事务人工核查任务清单.md index 21b37f3..b6b2dc0 100644 --- a/doc/部署事务人工核查任务清单.md +++ b/doc/部署事务人工核查任务清单.md @@ -1,5 +1,16 @@ # 部署事务人工核查任务清单 +## 2026年10月3日23:08 实机关闭误判整改任务 + +- [✓] 真实 SIGKILL 中断部署后,两条事务自动回滚,第二台正常 DNS 严格 HTTPS 仍通过。 +- [✓] 短暂限制 Scert 用户到第二台 SSH 后形成真实待恢复事务;限制已撤销,未改变历史证据或手动删锁。 +- [!] 正式人工提交完成远端收尾,却持久化 uncertain;该记录及占用保留,不直接改终态或重放。 +- [✓] 第一台 Linux 真实 SSH/SFTP 协议回归复现:健康连接主动关闭后返回连接已关闭错误,原用例失败。 +- [~] 修复主动关闭的预期 SFTP 错误分类和并发幂等,保留既有断连及非预期关闭错误。 +- [ ] 完成 Linux 竞态、执行器负向及后端回归,构建干净候选包并重新实机验证人工提交、回滚和断连查询。 + +本轮复用同一有效证书,不表示不同证书版本切换已验收;原两条无可信旧基线事务不在本次修复中解除。 + ## 2026年10月3日22:44 真实恢复拒绝证据 - [✓] 对事务 `20261003-142919.192475924-417b9bcc5b09`,真实本机终端选择恢复基线并完整确认;因历史 `previous_fingerprint` 为空,返回处置已拒绝,未执行远端变更。