优化SSH默认单次协商并记录重载回连阻塞
This commit is contained in:
parent
189b79849d
commit
1a317c2175
@ -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)
|
||||||
|
|||||||
98
backend/internal/deployment/ssh_negotiation_test.go
Normal file
98
backend/internal/deployment/ssh_negotiation_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 重载尚未取得本轮通过证据。
|
||||||
|
- [!] 下方历史记录保留原日期,不代表当前候选的验收状态。本轮未清库、未重启服务、未修改远端文件,不是首次初始化重测,不新增生产放行结论。
|
||||||
|
|
||||||
|
|
||||||
## 当前进展:发布说明修正与入口、依赖审计
|
## 当前进展:发布说明修正与入口、依赖审计
|
||||||
|
|
||||||
|
|||||||
@ -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 保持完成,待复审。** 本轮仅排查既有失败现场,没有新建测试资源、清理数据库或重启服务。
|
||||||
|
|||||||
@ -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。** 后续条目的源码实现、本地回归和实机验证分别记录;不得用局部进展替代整项复审,也不得因本节没有升级状态而忽略后续已完成的开发子项。
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user