优化SSH默认单次协商并记录重载回连阻塞

This commit is contained in:
xuans 2026-09-30 19:09:52 +08:00
parent 189b79849d
commit 1a317c2175
5 changed files with 161 additions and 3 deletions

View File

@ -85,7 +85,7 @@ func (f SSHFactory) connect(ctx context.Context, server Server, requireSFTP bool
} }
timeoutContext, cancel := context.WithTimeout(ctx, timeout) timeoutContext, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
sshClient, err := connectSSHClient(timeoutContext, server, authMethods, algorithms) sshClient, err := connectSSHClient(timeoutContext, server, authMethods, algorithms, len(f.HostKeyAlgorithms) == 0)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -145,10 +145,19 @@ func connectSSHClient(
server Server, server Server,
authMethods []ssh.AuthMethod, authMethods []ssh.AuthMethod,
algorithms []string, algorithms []string,
preferSingleNegotiation bool,
) (*ssh.Client, error) { ) (*ssh.Client, error) {
address := net.JoinHostPort(server.Address, fmt.Sprintf("%d", server.Port)) address := net.JoinHostPort(server.Address, fmt.Sprintf("%d", server.Port))
var mismatchErrors []string var mismatchErrors []string
algorithmGroups := make([][]string, 0, len(algorithms)+1)
if preferSingleNegotiation {
algorithmGroups = append(algorithmGroups, algorithms)
}
for _, algorithm := range algorithms { for _, algorithm := range algorithms {
algorithmGroups = append(algorithmGroups, []string{algorithm})
}
for _, algorithmGroup := range algorithmGroups {
algorithm := strings.Join(algorithmGroup, ",")
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("连接 SSH 服务器超时:%w", err) return nil, fmt.Errorf("连接 SSH 服务器超时:%w", err)
} }
@ -167,7 +176,7 @@ func connectSSHClient(
config := &ssh.ClientConfig{ config := &ssh.ClientConfig{
User: server.Username, User: server.Username,
Auth: authMethods, Auth: authMethods,
HostKeyAlgorithms: []string{algorithm}, HostKeyAlgorithms: algorithmGroup,
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error { HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
fingerprint := ssh.FingerprintSHA256(key) fingerprint := ssh.FingerprintSHA256(key)
if fingerprint != server.HostKeyFingerprint { if fingerprint != server.HostKeyFingerprint {
@ -201,7 +210,7 @@ func connectSSHClient(
continue continue
} }
var negotiationError *ssh.AlgorithmNegotiationError 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 continue
} }
return nil, fmt.Errorf("SSH 握手失败:%w", handshakeErr) return nil, fmt.Errorf("SSH 握手失败:%w", handshakeErr)

View File

@ -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)
}
}

View File

@ -1,4 +1,16 @@
# Linux 生产流程测试记录 # 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 重载尚未取得本轮通过证据。
- [!] 下方历史记录保留原日期,不代表当前候选的验收状态。本轮未清库、未重启服务、未修改远端文件,不是首次初始化重测,不新增生产放行结论。
## 当前进展:发布说明修正与入口、依赖审计 ## 当前进展:发布说明修正与入口、依赖审计

View File

@ -1,4 +1,24 @@
# Scert 整改任务执行清单 # 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,版本与日志只读核对) ## 最新执行记录(2026-09-30 15:26,版本与日志只读核对)
**P0-03 保持完成,待复审。** 本轮仅排查既有失败现场,没有新建测试资源、清理数据库或重启服务。 **P0-03 保持完成,待复审。** 本轮仅排查既有失败现场,没有新建测试资源、清理数据库或重启服务。

View File

@ -1,4 +1,23 @@
# Scert 整改、合并与优化清单 # 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,验收位置与开发进展分开记录) ## 当前进度口径(2026-09-30 15:26,验收位置与开发进展分开记录)
**总体验收位置仍为 P0-03:完成,待复审,不代表全部开发只做到 P0-03。** 后续条目的源码实现、本地回归和实机验证分别记录;不得用局部进展替代整项复审,也不得因本节没有升级状态而忽略后续已完成的开发子项。 **总体验收位置仍为 P0-03:完成,待复审,不代表全部开发只做到 P0-03。** 后续条目的源码实现、本地回归和实机验证分别记录;不得用局部进展替代整项复审,也不得因本节没有升级状态而忽略后续已完成的开发子项。