diff --git a/backend/internal/deployment/ssh.go b/backend/internal/deployment/ssh.go index 3179c5e..1d94e0c 100644 --- a/backend/internal/deployment/ssh.go +++ b/backend/internal/deployment/ssh.go @@ -85,7 +85,7 @@ func (f SSHFactory) connect(ctx context.Context, server Server, requireSFTP bool } timeoutContext, cancel := context.WithTimeout(ctx, timeout) defer cancel() - sshClient, err := connectSSHClient(timeoutContext, server, authMethods, algorithms) + sshClient, err := connectSSHClient(timeoutContext, server, authMethods, algorithms, len(f.HostKeyAlgorithms) == 0) if err != nil { return nil, err } @@ -145,10 +145,19 @@ func connectSSHClient( server Server, authMethods []ssh.AuthMethod, algorithms []string, + preferSingleNegotiation bool, ) (*ssh.Client, error) { address := net.JoinHostPort(server.Address, fmt.Sprintf("%d", server.Port)) var mismatchErrors []string + algorithmGroups := make([][]string, 0, len(algorithms)+1) + if preferSingleNegotiation { + algorithmGroups = append(algorithmGroups, algorithms) + } for _, algorithm := range algorithms { + algorithmGroups = append(algorithmGroups, []string{algorithm}) + } + for _, algorithmGroup := range algorithmGroups { + algorithm := strings.Join(algorithmGroup, ",") if err := ctx.Err(); err != nil { return nil, fmt.Errorf("连接 SSH 服务器超时:%w", err) } @@ -167,7 +176,7 @@ func connectSSHClient( config := &ssh.ClientConfig{ User: server.Username, Auth: authMethods, - HostKeyAlgorithms: []string{algorithm}, + HostKeyAlgorithms: algorithmGroup, HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error { fingerprint := ssh.FingerprintSHA256(key) if fingerprint != server.HostKeyFingerprint { @@ -201,7 +210,7 @@ func connectSSHClient( continue } var negotiationError *ssh.AlgorithmNegotiationError - if errors.As(handshakeErr, &negotiationError) && negotiationError.What == "host key" { + if len(algorithmGroup) == 1 && errors.As(handshakeErr, &negotiationError) && negotiationError.What == "host key" { continue } return nil, fmt.Errorf("SSH 握手失败:%w", handshakeErr) diff --git a/backend/internal/deployment/ssh_negotiation_test.go b/backend/internal/deployment/ssh_negotiation_test.go new file mode 100644 index 0000000..9ab659f --- /dev/null +++ b/backend/internal/deployment/ssh_negotiation_test.go @@ -0,0 +1,98 @@ +package deployment + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "golang.org/x/crypto/ssh" +) + +func TestSSHFactoryDefaultNegotiatesOnce(t *testing.T) { + for _, reject := range []bool{false, true} { + name := "accepted" + if reject { + name = "rejected" + } + t.Run(name, func(t *testing.T) { + _, privateKey := newTestSSHSigner(t) + hostSigner, _ := newTestSSHSigner(t) + server := startTestSSHServer(t, func(ssh.PublicKey) error { + if reject { + return errors.ErrUnsupported + } + return nil + }, hostSigner) + factory := SSHFactory{ + Secrets: staticSecretReader{"secret://test": {"private_key": privateKey}}, + Timeout: 2 * time.Second, + } + client, err := factory.ConnectShell(t.Context(), testSSHServerModel( + server, "secret://test", ssh.FingerprintSHA256(hostSigner.PublicKey()), + )) + if client != nil { + defer client.Close() + } + if (err != nil) != reject { + t.Fatalf("reject=%t err=%v", reject, err) + } + if got := server.accepted.Load(); got != 1 { + t.Fatalf("connections=%d; want=1", got) + } + }) + } +} + +func TestSSHSingleNegotiationFallsBackToRegisteredFingerprint(t *testing.T) { + clientSigner, _ := newTestSSHSigner(t) + ecdsaHostSigner := newTestECDSAHostSigner(t) + ed25519HostSigner, _ := newTestSSHSigner(t) + var authenticated atomic.Int32 + server := startTestSSHServer(t, func(ssh.PublicKey) error { + authenticated.Add(1) + return nil + }, ecdsaHostSigner, ed25519HostSigner) + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + client, err := connectSSHClient(ctx, + testSSHServerModel(server, "secret://test", ssh.FingerprintSHA256(ed25519HostSigner.PublicKey())), + []ssh.AuthMethod{ssh.PublicKeys(clientSigner)}, + []string{ssh.KeyAlgoECDSA256, ssh.KeyAlgoED25519}, true, + ) + if err != nil { + t.Fatal(err) + } + defer client.Close() + if got := server.accepted.Load(); got != 3 { + t.Fatalf("connections=%d; want=3", got) + } + if got := authenticated.Load(); got != 1 { + t.Fatalf("authenticated=%d; want=1", got) + } +} + +func TestSSHFactoryDefaultRejectsUnknownFingerprintBeforeAuthentication(t *testing.T) { + _, privateKey := newTestSSHSigner(t) + hostSigner, _ := newTestSSHSigner(t) + var authenticated atomic.Int32 + server := startTestSSHServer(t, func(ssh.PublicKey) error { + authenticated.Add(1) + return nil + }, hostSigner) + factory := SSHFactory{ + Secrets: staticSecretReader{"secret://test": {"private_key": privateKey}}, + Timeout: 2 * time.Second, + } + client, err := factory.ConnectShell(t.Context(), testSSHServerModel(server, "secret://test", "SHA256:unregistered")) + if client != nil { + defer client.Close() + } + if err == nil { + t.Fatal(err) + } + if got := authenticated.Load(); got != 0 { + t.Fatalf("authenticated=%d; want=0", got) + } +} diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 6473d0c..59a318d 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,4 +1,16 @@ # Linux 生产流程测试记录 +### 2026-09-30 19:10 现场证据补充 + +本地竞态检查已全部通过。远端密码 SSH 读取精确日志显示:SSH 于 10:27:40.559700 UTC 收到重载信号,Scert 于 10:27:40.566606 UTC 私钥连接被拒绝,SSH 于 10:27:40.583273 UTC 恢复监听。该次失败处于监听重建窗口;候选仍需补充公钥安装后有界重试,再构建部署复核。此处仅是历史失败日志的定位证据,不是重新通过生产验收。 + +## 最新状态(2026-09-30 19:08,SSH 修复候选本地验证) + +- [x] 已用密码 SSH 和严格已登记主机校验连接授权目标;只读执行服务状态及日志查询,Scert 和 SSH 均为 active。 +- [x] 本地默认单次协商回归通过;后端全量测试、静态检查及构建通过。 +- [~] 竞态检查正在运行;候选尚未重新发布到远端。 +- [ ] 自动密钥登录、真实证书推送与 Nginx 重载尚未取得本轮通过证据。 +- [!] 下方历史记录保留原日期,不代表当前候选的验收状态。本轮未清库、未重启服务、未修改远端文件,不是首次初始化重测,不新增生产放行结论。 + ## 当前进展:发布说明修正与入口、依赖审计 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 92e18b4..8589921 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,4 +1,24 @@ # Scert 整改任务执行清单 +### 19:10 执行补充 + +- [x] 部署、接口、应用包竞态检查全部通过,退出 0。 +- [x] 精确远端日志确认:私钥回连处于 SSH 重载与恢复监听之间,相差约 17 毫秒;没有发现本次失败需要通过关闭校验或改用密码部署来解决。 +- [~] 当前新增任务:公钥安装后短暂连接拒绝的有界重试,覆盖成功恢复、耗尽、认证拒绝和上下文取消。 +- [ ] 本批修复提交后再开始该项修改;远端部署与生产验收仍待执行。 + +## 最新执行记录(2026-09-30 19:08,单次 SSH 协商) + +本轮沿用已确认需求:密码连接自动部署密钥,严格校验主机指纹,复用既有服务器记录,不以辅助脚本或单元测试代替真实生产验收。总体验收仍为 **P0-03:完成,待复审**。 + +- [x] 修改前中文基线提交 189b798 已推送。 +- [x] SSH 默认改为单次多算法协商;指纹不匹配再回退,认证失败停止重连;显式算法列表兼容行为保留。 +- [x] 新增四个叶子回归场景,专项测试及后端全量测试、静态检查、构建通过。 +- [~] 部署、接口、应用三个包的竞态检查正在运行。 +- [x] 密码 SSH 只读检查确认远端 Scert、SSH 正常运行;不算自动密钥登录成功证据。 +- [ ] 提交修复、构建正式候选并部署,验证更新既有服务器接口返回 200、密钥状态 deployed、错误为空、部署与测试时间有值。 +- [ ] 继续证书目录创建、证书推送、Nginx 校验与重载、失败回滚、真实多对多和三数据库验收。 +- [!] 第二台真实服务器缺失仍未解决;本轮不新增复审通过项,不再使用未经逐项统计的完成百分比。 + ## 最新执行记录(2026-09-30 15:26,版本与日志只读核对) **P0-03 保持完成,待复审。** 本轮仅排查既有失败现场,没有新建测试资源、清理数据库或重启服务。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index b86d118..778d7cc 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,4 +1,23 @@ # Scert 整改、合并与优化清单 +### 19:10 补充:回连失败现场已定位到 SSH 重载窗口 + +- [x] 三个包的竞态检查全部通过:部署 9.381 秒、接口 172.202 秒、应用 74.521 秒,命令退出 0。 +- [x] 密码 SSH 只读核对精确日志:10:27:40.559700 UTC 收到重载信号,10:27:40.566606 UTC 私钥连接被拒绝,10:27:40.583273 UTC SSH 恢复监听。该次失败位于重载监听间隙,不能只归因于算法协商次数。 +- [~] 下一修复项:仅在公钥安装后的私钥回连阶段,对连接拒绝增加有界、可取消的重试;认证失败与指纹不匹配不得重试或放宽。 +- [ ] 单次协商优化尚未远端部署;总体验收仍为 P0-03 完成待复审。 + +## 当前进度口径(2026-09-30 19:08,单次 SSH 协商本地回归) + +**总体验收仍为 P0-03:完成,待复审,不代表生产验收通过。** 此前口头“约 70%”未按清单逐项统计,撤回该百分比;以下以实际完成项和未完成门禁为准。 + +- [x] 默认 SSH 连接改为一次协商全部已过滤的安全主机密钥算法,正常连接只建立一次 TCP 连接;显式指定算法仍保持逐算法兼容行为。 +- [x] 已新增正常连接、认证失败停止重连、指纹不匹配回退、未知指纹在认证前拒绝的回归测试;保留严格主机指纹校验。 +- [x] 本轮后端全量测试、静态检查、构建、差异检查通过;这些结果不等同于真实数据库矩阵或远端验收。 +- [~] 竞态检查正在运行;新候选尚未构建和部署,远端自动密钥回连仍未复测通过。 +- [x] 本轮密码 SSH 只读确认远端 Scert 与 SSH 服务均为 active;未清库、未修改远端文件,未创建辅助验收脚本。 +- [ ] 下一步:竞态检查、提交候选、构建正式包、部署并复测既有服务器记录,再执行证书推送、校验、重载与回滚。 +- [!] 连接次数过多是待验证假设,不能据此宣称 connection refused 的根因已解决。第二台真实服务器与完整生产门禁仍未完成,不能承诺当日全部验收。 + ## 当前进度口径(2026-09-30 15:26,验收位置与开发进展分开记录) **总体验收位置仍为 P0-03:完成,待复审,不代表全部开发只做到 P0-03。** 后续条目的源码实现、本地回归和实机验证分别记录;不得用局部进展替代整项复审,也不得因本节没有升级状态而忽略后续已完成的开发子项。