network-infra-utility 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +21 -0
- data/CHANGELOG.md +31 -4
- data/Gemfile +2 -0
- data/Gemfile.lock +70 -0
- data/Rakefile +1 -1
- data/bin/dns-query +834 -0
- data/bin/geo-doc +135 -0
- data/bin/geo-get +1 -1
- data/document/ASNum/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +242 -0
- data/document/DNSQuery/345/267/245/345/205/267/344/275/277/347/224/250/346/226/271/346/263/225.md +248 -0
- data/document/Geo/345/221/275/344/273/244/345/267/245/345/205/267/344/275/277/347/224/250/346/226/271/346/263/225.md +441 -0
- data/document/IP/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +297 -0
- data/document/MAC/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +296 -0
- data/document/SSH/350/277/236/346/216/245/345/256/242/346/210/267/347/253/257/344/275/277/347/224/250/346/226/271/346/263/225.md +764 -0
- data/network-infra-utility.gemspec +4 -2
- data/network.rb +3 -1
- data/service/geodb/GeoAPI.md +1 -0
- data/service/geodb/geodb.rb +278 -1
- data/service/ssh/README.md +942 -0
- data/service/ssh/bin/ssh-client +198 -0
- data/service/ssh/design/SSH/350/277/236/346/216/245/345/256/242/346/210/267/347/253/257/345/212/237/350/203/275/351/234/200/346/261/202/346/226/207/346/241/243.md +292 -0
- data/service/ssh/design/SSH/350/277/236/346/216/245/345/256/242/346/210/267/347/253/257/350/257/246/347/273/206/350/256/276/350/256/241/346/226/207/346/241/243.md +1521 -0
- data/service/ssh/design/SSH/350/277/236/346/216/245/345/256/242/346/210/267/347/253/257/350/275/257/344/273/266/350/256/276/350/256/241/346/226/207/346/241/243.md +2493 -0
- data/service/ssh/ext/ssh_core/bin/ssh_core.cmd +28 -0
- data/service/ssh/ext/ssh_core/config/sys.config +0 -0
- data/service/ssh/ext/ssh_core/config/vm.args +0 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/CHECKSUM +1 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/LICENSE +21 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/README.md +696 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/VERSION +1 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/contents.tar.gz +0 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/metadata.config +15 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/rebar.config +17 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/rebar.lock +1 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx.app.src +10 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx.erl +506 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_config.erl +393 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_config.hrl +18 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_consult.erl +81 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_decoder.erl +1909 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_encoder.erl +116 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_parser.erl +1214 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_to_json.erl +408 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_to_term.erl +389 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_verify.erl +121 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx.erl +506 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_config.erl +393 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_config.hrl +18 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_consult.erl +81 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_decoder.erl +1909 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_encoder.erl +116 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_parser.erl +1214 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_to_json.erl +408 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_to_term.erl +389 -0
- data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_verify.erl +121 -0
- data/service/ssh/ext/ssh_core/rebar.config +24 -0
- data/service/ssh/ext/ssh_core/rebar.lock +1 -0
- data/service/ssh/ext/ssh_core/src/ssh_auth_engine.erl +156 -0
- data/service/ssh/ext/ssh_core/src/ssh_channel_stm.erl +232 -0
- data/service/ssh/ext/ssh_core/src/ssh_codec.erl +83 -0
- data/service/ssh/ext/ssh_core/src/ssh_conn_sup.erl +48 -0
- data/service/ssh/ext/ssh_core/src/ssh_conn_worker.erl +535 -0
- data/service/ssh/ext/ssh_core/src/ssh_core.app.src +36 -0
- data/service/ssh/ext/ssh_core/src/ssh_core_app.erl +11 -0
- data/service/ssh/ext/ssh_core/src/ssh_core_sup.erl +46 -0
- data/service/ssh/ext/ssh_core/src/ssh_infra_sup.erl +104 -0
- data/service/ssh/ext/ssh_core/src/ssh_ipc.hrl +80 -0
- data/service/ssh/ext/ssh_core/src/ssh_ipc_coalesce.erl +94 -0
- data/service/ssh/ext/ssh_core/src/ssh_ipc_gateway.erl +467 -0
- data/service/ssh/ext/ssh_core/src/ssh_ipc_proto.erl +95 -0
- data/service/ssh/ext/ssh_core/src/ssh_jump_chain.erl +101 -0
- data/service/ssh/ext/ssh_core/src/ssh_keepalive_mgr.erl +222 -0
- data/service/ssh/ext/ssh_core/src/ssh_known_hosts_proxy.erl +67 -0
- data/service/ssh/ext/ssh_core/src/ssh_port_fwd.erl +225 -0
- data/service/ssh/ext/ssh_core/src/ssh_sftp_session.erl +250 -0
- data/service/ssh/ext/ssh_core/src/ssh_sftp_sup.erl +62 -0
- data/service/ssh/ext/ssh_core_rs/Cargo.lock +2345 -0
- data/service/ssh/ext/ssh_core_rs/Cargo.toml +30 -0
- data/service/ssh/ext/ssh_core_rs/bin/ssh_core_rs +34 -0
- data/service/ssh/ext/ssh_core_rs/bin/ssh_core_rs.cmd +40 -0
- data/service/ssh/ext/ssh_core_rs/src/channel.rs +296 -0
- data/service/ssh/ext/ssh_core_rs/src/coalesce.rs +143 -0
- data/service/ssh/ext/ssh_core_rs/src/codec.rs +71 -0
- data/service/ssh/ext/ssh_core_rs/src/conn.rs +628 -0
- data/service/ssh/ext/ssh_core_rs/src/gateway.rs +389 -0
- data/service/ssh/ext/ssh_core_rs/src/handler.rs +293 -0
- data/service/ssh/ext/ssh_core_rs/src/keepalive.rs +194 -0
- data/service/ssh/ext/ssh_core_rs/src/main.rs +351 -0
- data/service/ssh/ext/ssh_core_rs/src/portfwd.rs +378 -0
- data/service/ssh/ext/ssh_core_rs/src/proto.rs +198 -0
- data/service/ssh/ext/ssh_core_rs/src/sftp.rs +294 -0
- data/service/ssh/lib/network_infra_utility/ssh/automation/macro_engine.rb +213 -0
- data/service/ssh/lib/network_infra_utility/ssh/client.rb +257 -0
- data/service/ssh/lib/network_infra_utility/ssh/config/schema.rb +90 -0
- data/service/ssh/lib/network_infra_utility/ssh/config/settings.rb +103 -0
- data/service/ssh/lib/network_infra_utility/ssh/config/store.rb +90 -0
- data/service/ssh/lib/network_infra_utility/ssh/ipc/coalesce.rb +83 -0
- data/service/ssh/lib/network_infra_utility/ssh/ipc/errors.rb +36 -0
- data/service/ssh/lib/network_infra_utility/ssh/ipc/router.rb +212 -0
- data/service/ssh/lib/network_infra_utility/ssh/ipc/transport.rb +81 -0
- data/service/ssh/lib/network_infra_utility/ssh/security/host_key.rb +211 -0
- data/service/ssh/lib/network_infra_utility/ssh/security/vault.rb +211 -0
- data/service/ssh/lib/network_infra_utility/ssh/session/history.rb +56 -0
- data/service/ssh/lib/network_infra_utility/ssh/session/manager.rb +92 -0
- data/service/ssh/lib/network_infra_utility/ssh/session/session.rb +109 -0
- data/service/ssh/lib/network_infra_utility/ssh/session/tree.rb +95 -0
- data/service/ssh/lib/network_infra_utility/ssh/terminal/ansi_parser.rb +435 -0
- data/service/ssh/lib/network_infra_utility/ssh/terminal/buffer.rb +78 -0
- data/service/ssh/lib/network_infra_utility/ssh/terminal/emulator.rb +159 -0
- data/service/ssh/lib/network_infra_utility/ssh/terminal/logger.rb +195 -0
- data/service/ssh/lib/network_infra_utility/ssh/terminal/screen.rb +212 -0
- data/service/ssh/lib/network_infra_utility/ssh/terminal/theme.rb +127 -0
- data/service/ssh/lib/network_infra_utility/ssh/version.rb +7 -0
- data/service/ssh/lib/network_infra_utility/ssh.rb +44 -0
- data/support/basic/as_num.rb +221 -0
- data/support/basic/mac_address.rb +281 -0
- data/version.rb +1 -1
- metadata +138 -1
|
@@ -0,0 +1,1521 @@
|
|
|
1
|
+
|
|
2
|
+
# SSH 连接客户端详细设计文档(LLD)
|
|
3
|
+
|
|
4
|
+
> **文档版本**:v1.0
|
|
5
|
+
> **编写日期**:2026-08-02
|
|
6
|
+
> **文档状态**:初稿
|
|
7
|
+
> **对应文档**:SSH连接客户端功能需求文档 v1.0、SSH连接客户端软件设计文档 v1.0
|
|
8
|
+
> **定位**:本文件是 HLD(软件设计文档)的细化与修正,目标是对每个模块给出可落地的接口契约、状态机、数据结构、并发约定与"完成判定标准",使开发人员据此即可编码。
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 目录
|
|
13
|
+
|
|
14
|
+
- [1 文档定位与 HLD 的关系](#1-文档定位与-hld-的关系)
|
|
15
|
+
- [2 HLD 勘误与修正项](#2-hld-勘误与修正项)
|
|
16
|
+
- [3 模块全景与依赖图](#3-模块全景与依赖图)
|
|
17
|
+
- [4 全局不变量与 ID 规则](#4-全局不变量与-id-规则)
|
|
18
|
+
- [5 IPC 协议详细规范(v2)](#5-ipc-协议详细规范v2)
|
|
19
|
+
- [6 Erlang 核心引擎详细设计](#6-erlang-核心引擎详细设计)
|
|
20
|
+
- [7 Ruby 调度层详细设计](#7-ruby-调度层详细设计)
|
|
21
|
+
- [8 错误处理与级联策略](#8-错误处理与级联策略)
|
|
22
|
+
- [9 配置体系与数据一致性](#9-配置体系与数据一致性)
|
|
23
|
+
- [10 可观测性设计](#10-可观测性设计)
|
|
24
|
+
- [11 模块完成判定标准(Definition of Done)](#11-模块完成判定标准definition-of-done)
|
|
25
|
+
- [12 测试矩阵](#12-测试矩阵)
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 1 文档定位与 HLD 的关系
|
|
30
|
+
|
|
31
|
+
HLD 已确定:Erlang/OTP `ssh` 应用做协议核心、Ruby 做调度与终端、二者经 JSON-RPC 2.0 over Unix Socket/TCP 通信。本文件不推翻上述决策,只做三件事:
|
|
32
|
+
|
|
33
|
+
1. **修正**:HLD 中引用了 OTP ssh 应用中并不存在的 API,逐一替换为真实 API。
|
|
34
|
+
2. **细化**:HLD 中只给目录占位的模块,补全接口签名、状态、并发模型。
|
|
35
|
+
3. **补缺**:补 HLD 未覆盖的 IPC 生命周期/流控、全局不变量、错误级联、配置 schema、可观测性、完成判定。
|
|
36
|
+
|
|
37
|
+
本文件只覆盖 V1.0(P0)范围;V2.0/V3.0 模块列出接口预留位但不展开实现细节。
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 2 HLD 勘误与修正项
|
|
42
|
+
|
|
43
|
+
| # | HLD 位置 | 问题 | 修正 |
|
|
44
|
+
|---|---------|------|------|
|
|
45
|
+
| E1 | 4.2.4 `ssh:auth_user/3` | OTP ssh 无此函数,认证在 `ssh:connect` 选项中配置 | 改为在 `ssh:connect/2,3` 的 options 中传 `{user, User}`、`{password, Pwd}`、`{key_cb, ...}`、`{auth_method, ...}`,连接成功即认证完成 |
|
|
46
|
+
| E2 | 4.2.4 `ssh:load_host_key/2` | 无此函数 | 用 `public_key:read_keyfile/1,2` 或自定义 `key_cb` 回调返回 `{ok, Key, Algorithm}` |
|
|
47
|
+
| E3 | 4.2.2 `ssh:connect_via/2` | 无此函数,无法直接在已有通道上叠 SSH | 跳板改用 OpenSSH 语义:第一跳 `ssh:connect`;后续每跳在前一跳内开 `direct_tcpip` 通道拿到底层 socket,再对该 socket 跑 `ssh:connect` 的 connect 阶段(需自定义 transport,见 6.7) |
|
|
48
|
+
| E4 | 4.2.6 `ssh:tcpip_tunnel/...` | OTP ssh 无 `tcpip_tunnel` | 本地转发用 `ssh_connection:direct_tcpip` + 自建本地 listener;远程转发用 `ssh_connection:tcpip_forward` 注册远端端口 |
|
|
49
|
+
| E5 | 4.2.3 `gen_fsm` | OTP 26 起 `gen_fsm` 已弃用 | 全部改用 `gen_statem`(state_functions 模式) |
|
|
50
|
+
| E6 | 4.2.2 `tunneled_connect` | 直接 `ssh:connect` 无法复用底层通道 | 跳板链用 `ssh` 应用的 `connection callback` 或直接对裸 socket 执行握手;V1.0 简化为:每跳用 `ssh:connect`,跳板链通过 `proxy` 选项 + 自定义 `{sock_fun, Fun}` 实现 |
|
|
51
|
+
| E7 | 2.2 known_hosts 归属 | HLD 同时说 Erlang ETS 又说 Ruby `known_hosts.yml`,矛盾 | 统一:**Erlang 负责校验**(otp ssh 的 `key_cb`),**持久化由 Ruby 经 IPC 提供**;Erlang 不落盘 known_hosts |
|
|
52
|
+
| E8 | 5.2.2 `on_event` 永久回调 | 回调不清理,会内存泄漏且通道关闭后仍收到推送 | 引入 `channel_id` 维度的订阅注册/注销,`channel.close` 时清理 |
|
|
53
|
+
| E9 | 6 `channel.data` 无背压 | 高频小包冲爆 Ruby | Erlang 侧 coalesce + watermark;Ruby 侧批量消费,见 5.4 |
|
|
54
|
+
| E10 | 4.5 `conn_pool_sup` intensity=100 | 单连接崩溃即重启冲击 | `temporary`+simple_one_for_one 下 intensity 不影响单连接,但需把 ipc_gateway 与 keepalive 独立到不同 supervisor,避免连带 |
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 3 模块全景与依赖图
|
|
59
|
+
|
|
60
|
+
### 3.1 模块清单与职责边界
|
|
61
|
+
|
|
62
|
+
#### Erlang 侧(V1.0 必交付)
|
|
63
|
+
|
|
64
|
+
| 模块 | 类型 | 职责 | 不负责 |
|
|
65
|
+
|------|------|------|--------|
|
|
66
|
+
| `ssh_core_sup` | supervisor(顶层) | 拉起并监督三个子 supervisor/workers | 业务逻辑 |
|
|
67
|
+
| `ssh_infra_sup` | supervisor | 监督 ipc_gateway、keepalive_mgr、known_hosts_proxy | 连接进程 |
|
|
68
|
+
| `ssh_conn_sup` | supervisor(simple_one_for_one) | 动态拉起 conn_worker | 连接内部逻辑 |
|
|
69
|
+
| `ssh_sftp_sup` | supervisor(simple_one_for_one) | 动态拉起 sftp_session | SFTP 协议细节 |
|
|
70
|
+
| `ssh_ipc_gateway` | gen_server | 监听 IPC、路由 RPC、校验 token、coalesce 推送 | SSH 协议 |
|
|
71
|
+
| `ssh_ipc_proto` | module | JSON-RPC 编解码、分帧 | 网络 |
|
|
72
|
+
| `ssh_ipc_coalesce` | gen_server | 高频推送合并、背压 watermark | 路由 |
|
|
73
|
+
| `ssh_conn_worker` | gen_statem | 单条 SSH 连接生命周期 + 通道管理 | 多连接编排 |
|
|
74
|
+
| `ssh_channel_stm` | gen_statem | 单通道(shell/exec/sftp子系统)状态机 | 跨通道 |
|
|
75
|
+
| `ssh_auth_engine` | module | 认证链回退、key_cb 实现 | 凭据存储 |
|
|
76
|
+
| `ssh_jump_chain` | module | 跳板链顺序构建 + 自定义 sock_fun | 认证细节 |
|
|
77
|
+
| `ssh_port_fwd` | gen_server | 本地/远程/动态端口转发规则与 listener | 业务路由 |
|
|
78
|
+
| `ssh_sftp_session` | gen_server | 单 SFTP 会话操作 | 通道复用 |
|
|
79
|
+
| `ssh_keepalive_mgr` | gen_server | 周期巡检、连续失败计数、触发重连 | 重连执行 |
|
|
80
|
+
| `ssh_known_hosts_proxy` | gen_server | 经 IPC 向 Ruby 查询主机密钥裁决 | 落盘 |
|
|
81
|
+
|
|
82
|
+
#### Ruby 侧(V1.0 必交付)
|
|
83
|
+
|
|
84
|
+
| 模块 | 类型 | 职责 | 不负责 |
|
|
85
|
+
|------|------|------|--------|
|
|
86
|
+
| `SSH::Client` | class | 生命周期、引擎拉起、模块组合 | 协议细节 |
|
|
87
|
+
| `IPC::Transport` | class | socket 读写、分帧、重连 | 业务路由 |
|
|
88
|
+
| `IPC::Router` | class | 请求-响应配对、推送分发、订阅注册/注销 | socket |
|
|
89
|
+
| `IPC::Coalesce` | class(push消费) | 批量消费 channel.data,批量喂终端 | 协议 |
|
|
90
|
+
| `Session::Manager` | class | 会话集合、连接编排、树形分组、历史 | 单会话内部 |
|
|
91
|
+
| `Session::Session` | class | 单会话聚合终端/文件/转发 | 跨会话 |
|
|
92
|
+
| `Session::Tree` | class | 分组树(CRUD+折叠) | 持久化 |
|
|
93
|
+
| `Session::History` | class | 最近列表、置顶 | — |
|
|
94
|
+
| `Terminal::Emulator` | class | ANSI/xterm 转义解析、屏幕状态 | 渲染 I/O |
|
|
95
|
+
| `Terminal::Screen` | class | 屏幕行/光标/滚动区 | 转义语义 |
|
|
96
|
+
| `Terminal::Buffer` | class | 回滚区 + 搜索索引 | 渲染 |
|
|
97
|
+
| `Terminal::Theme` | class | 配色加载/查询 | — |
|
|
98
|
+
| `Terminal::Logger` | class | 会话日志、轮转 | 渲染 |
|
|
99
|
+
| `Security::Vault` | class | AES-256-GCM 凭据加解密 | 凭据引用解析 |
|
|
100
|
+
| `Security::HostKey` | class | known_hosts 落盘 + 裁决 | 校验调用 |
|
|
101
|
+
| `Automation::MacroEngine` | class | 登录宏步骤执行 | 命令产生 |
|
|
102
|
+
| `Config::Settings` | class | 全局设置加载、观察 | 业务数据 |
|
|
103
|
+
| `Config::Schema` | module | 配置校验、版本迁移 | 加载 |
|
|
104
|
+
| `Config::Store` | class | 会话/分组/片段的统一持久化 | schema |
|
|
105
|
+
|
|
106
|
+
#### V2.0/V3.0 预留位(仅接口位,不展开)
|
|
107
|
+
|
|
108
|
+
TUI(Curses)、ImportExport、Search、KeyManager、Proxy、Diagnostics、BatchExec、SnippetManager、Zmodem、Watcher、OTP、Stats、ScriptEngine、Recorder、Sync、TeamShare。每个在 HLD 的 9.1 已有映射,本文件第 7 章给出其与 V1.0 模块的对接点是哪个 method/class。
|
|
109
|
+
|
|
110
|
+
### 3.2 依赖图(编译期/运行期)
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
编译期依赖(仅向左依赖,禁止环):
|
|
114
|
+
ssh_ipc_proto ── ssh_codec
|
|
115
|
+
ssh_auth_engine ── ssh_ipc_proto
|
|
116
|
+
ssh_jump_chain ── ssh_codec
|
|
117
|
+
ssh_channel_stm ── ssh_ipc_proto
|
|
118
|
+
ssh_conn_worker ── {ssh_channel_stm, ssh_auth_engine, ssh_jump_chain, ssh_keepalive_mgr}
|
|
119
|
+
ssh_conn_sup ── ssh_conn_worker
|
|
120
|
+
ssh_keepalive_mgr ── ssh_conn_sup(仅查列表,不持引用)
|
|
121
|
+
ssh_ipc_gateway ── {ssh_ipc_proto, ssh_ipc_coalesce, ssh_conn_sup, ssh_sftp_sup, ssh_known_hosts_proxy}
|
|
122
|
+
ssh_core_sup ── {ssh_infra_sup, ssh_conn_sup, ssh_sftp_sup}
|
|
123
|
+
ssh_infra_sup ── {ssh_ipc_gateway, ssh_keepalive_mgr, ssh_known_hosts_proxy}
|
|
124
|
+
|
|
125
|
+
运行期调用方向(RPC 入口在 gateway,向下分发):
|
|
126
|
+
Ruby IPC::Router
|
|
127
|
+
│
|
|
128
|
+
▼ (Unix Socket/TCP)
|
|
129
|
+
ssh_ipc_gateway ──dispatch──▶ {ssh_conn_worker, ssh_sftp_session, ssh_known_hosts_proxy, ssh_port_fwd}
|
|
130
|
+
│
|
|
131
|
+
▼
|
|
132
|
+
ssh_channel_stm ──push──▶ ssh_ipc_coalesce ──▶ ssh_ipc_gateway ──▶ Ruby
|
|
133
|
+
|
|
134
|
+
禁止反向调用:Erlang 模块不得直接调用 Ruby;Ruby 不可达时 push 丢弃并记日志。
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 4 全局不变量与 ID 规则
|
|
140
|
+
|
|
141
|
+
本节定义跨模块共享的契约。任何模块实现不得违反下列条款。
|
|
142
|
+
|
|
143
|
+
### 4.1 ID 规则
|
|
144
|
+
|
|
145
|
+
| 标识 | 格式 | 分配方 | 作用域 | 跨重启 |
|
|
146
|
+
|------|------|--------|--------|--------|
|
|
147
|
+
| `conn_id` | `conn_<unix_ms>_<8hex>` | Erlang `ssh_conn_worker:init` | 全局(单引擎进程内) | 不复用,新连接新 ID |
|
|
148
|
+
| `channel_id` | `ch_<conn_id_short>_<seq>` | Erlang `ssh_conn_worker`(连接内自增) | 连接内唯一 | 不复用 |
|
|
149
|
+
| `sftp_id` | `sftp_<conn_id_short>_<seq>` | Erlang `ssh_sftp_sup` | 连接内唯一 | 不复用 |
|
|
150
|
+
| `rule_id` | `fwd_<conn_id_short>_<seq>` | Erlang `ssh_port_fwd` | 连接内唯一 | 不复用 |
|
|
151
|
+
| `rpc_id` | 自增整数 | Ruby `IPC::Router` | 单条 IPC 连接内 | — |
|
|
152
|
+
| `session_id` | `sess_<uuid_v4>` | Ruby `Session::Manager` | 客户端进程内 | 不复用 |
|
|
153
|
+
| `group_id` | `grp_<8hex>` | Ruby `Session::Tree` | 配置文件内 | 持久化、跨重启复用 |
|
|
154
|
+
|
|
155
|
+
**关键不变量**:
|
|
156
|
+
- `channel_id` 是**连接内唯一**,非全局唯一。Ruby 侧用 `(conn_id, channel_id)` 复合键索引。
|
|
157
|
+
- `conn_id` 由 Erlang 分配并在 `conn.ready` 事件回传 Ruby;Ruby 的 `session_id` 与 `conn_id` 一对一映射,存于 `Session` 对象。
|
|
158
|
+
- 所有 ID 仅含 ASCII,可安全作为 JSON 字符串与文件名片段。
|
|
159
|
+
|
|
160
|
+
### 4.2 字符串编码
|
|
161
|
+
|
|
162
|
+
| 边界 | 编码 | 备注 |
|
|
163
|
+
|------|------|------|
|
|
164
|
+
| IPC 文本字段 | UTF-8 | JSON 字符串默认 |
|
|
165
|
+
| 终端数据 | 原始字节 → Base64 | 二进制安全,解码后按终端声明的 encoding 解释 |
|
|
166
|
+
| 文件路径 | UTF-8 字符串 | Erlang 侧 `unicode:characters_to_list/2` 处理 |
|
|
167
|
+
| 日志 | UTF-8 | 结构化字段见第 10 章 |
|
|
168
|
+
|
|
169
|
+
### 4.3 时钟与时间戳
|
|
170
|
+
|
|
171
|
+
- 所有 RPC 中的时间戳为 **Unix 毫秒整数**(Erlang 侧 `erlang:system_time(millisecond)`,Ruby 侧不使用 `Time.now.strftime`,而是用 `(Time.now.to_f*1000).to_i`)。
|
|
172
|
+
- 超时一律用毫秒在协议层表达;Ruby API 可暴露秒。
|
|
173
|
+
- 不依赖两端时钟同步;超时由发起方倒计时。
|
|
174
|
+
|
|
175
|
+
### 4.4 并发所有权
|
|
176
|
+
|
|
177
|
+
| 资源 | 拥有者 | 访问规则 |
|
|
178
|
+
|------|--------|---------|
|
|
179
|
+
| `conn_worker` 进程 | `ssh_conn_sup` | 通过 `{via, Registry, conn_id}` 注册表寻址;外部经 RPC 操作 |
|
|
180
|
+
| `channel_stm` 进程 | 父 `conn_worker` | 父进程 link,父退出则子终止 |
|
|
181
|
+
| `sftp_session` 进程 | `ssh_sftp_sup` | 经 `conn_id` 关联,conn_worker 不持有 pid |
|
|
182
|
+
| Ruby `Session` 对象 | `Session::Manager` 的数组/Ruby `Session` Hash | 主线程访问;event_loop 线程只读 `conn_id→session` 映射,经 `Queue` 投递到主线程处理 |
|
|
183
|
+
| Ruby `Vault` | 主线程 | 加解密仅主线程;`connect` 在主线程 |
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## 5 IPC 协议详细规范(v2)
|
|
188
|
+
|
|
189
|
+
HLD 第 6 章定义了 JSON-RPC 2.0 消息格式与方法清单,但缺生命周期、分帧、流控。本节补齐。
|
|
190
|
+
|
|
191
|
+
### 5.1 分帧规则
|
|
192
|
+
|
|
193
|
+
- 每条 JSON-RPC 消息以单个 `\n`(0x0A)结尾。
|
|
194
|
+
- 消息内不得含裸 `\n`;JSON 序列化天然满足(字符串内 `\n` 被转义)。
|
|
195
|
+
- 接收方按 `\n` 切分,未遇 `\n` 前缓冲在接收 buffer,单消息上限 **2 MB**;超限即断连并记 `parse_error`。
|
|
196
|
+
- 终端/SFTP 原始字节经 Base64 编码后放入 `data` 字段,天然无裸 `\n`。
|
|
197
|
+
|
|
198
|
+
### 5.2 连接生命周期
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
Ruby Erlang ipc_gateway
|
|
202
|
+
│ │
|
|
203
|
+
│ TCP/Unix connect │
|
|
204
|
+
│────────────────────────────────▶│
|
|
205
|
+
│ │
|
|
206
|
+
│ rpc: hello {auth_token, ver} │
|
|
207
|
+
│────────────────────────────────▶│
|
|
208
|
+
│ │ 校验 token + ver 兼容
|
|
209
|
+
│ result: {server_ver, server_id}│
|
|
210
|
+
│◀────────────────────────────────│
|
|
211
|
+
│ │
|
|
212
|
+
│ … 业务 RPC / 推送 … │
|
|
213
|
+
│ │
|
|
214
|
+
│ rpc: bye │
|
|
215
|
+
│────────────────────────────────▶│
|
|
216
|
+
│ result: {ok} │
|
|
217
|
+
│◀────────────────────────────────│
|
|
218
|
+
│ close │
|
|
219
|
+
│────────────────────────────────▶│
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**hello**:
|
|
223
|
+
```json
|
|
224
|
+
// Ruby → Erlang
|
|
225
|
+
{"jsonrpc":"2.0","id":1,"method":"hello","params":{"auth_token":"<32hex>","ver":"1.0","client_id":"ruby-<pid>"}}
|
|
226
|
+
// Erlang → Ruby
|
|
227
|
+
{"jsonrpc":"2.0","id":1,"result":{"server_ver":"1.0.0","server_id":"beam-<node>","capabilities":["coalesce","batch_ack"]}}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
未通过 hello 的后续请求一律返回 `-32000 Unauthorized`。hello 必须是第一条消息且 id 固定为 1。
|
|
231
|
+
|
|
232
|
+
**bye**:Ruby 退出前发送,Erlang 优雅清理该客户端的订阅;5 秒未收到 bye 直接断连也允许。
|
|
233
|
+
|
|
234
|
+
### 5.3 心跳
|
|
235
|
+
|
|
236
|
+
| 方向 | 方法 | 周期 | 超时判定 |
|
|
237
|
+
|------|------|------|---------|
|
|
238
|
+
| Ruby→Erlang | `engine.ping` | 15s | 连续 2 次无响应 → 判定引擎失联,Ruby 侧 `Client#stop` 并尝试重启 |
|
|
239
|
+
|
|
240
|
+
### 5.4 推送流控(背压)
|
|
241
|
+
|
|
242
|
+
**问题**:终端高频小包(`yes` 指令每秒数千包)逐条 push 会让 Ruby 线程饱和。
|
|
243
|
+
|
|
244
|
+
**机制**(Erlang 侧 `ssh_ipc_coalesce`):
|
|
245
|
+
1. `channel_stm` 收到 SSH 数据后不入 gateway 队列,而是写入 per-channel 的 coalesce buffer。
|
|
246
|
+
2. `ssh_ipc_coalesce` 每 **8ms** 或 buffer 达到 **16 KB** 时的下一次 tick 合并为一帧:
|
|
247
|
+
```json
|
|
248
|
+
{"jsonrpc":"2.0","method":"channel.data.batch","params":{"items":[{"id":"ch_..","data":".."},{"id":"ch_..","data":".."}]}}
|
|
249
|
+
```
|
|
250
|
+
3. watermark:当 Ruby 侧 socket send buffer 排队 > **512 KB**,gateway 暂停从 coalesce 取数据,并发 `channel.flow.pause`{conn_id, channel_id};低于 128 KB 恢复并发 `channel.flow.resume`。
|
|
251
|
+
4. Ruby 侧 `IPC::Coalesce` 收到 batch 后按 channel 分发,避免每包一次回调。
|
|
252
|
+
|
|
253
|
+
**能力协商**:hello 结果里若 `capabilities` 含 `coalesce`,则 Ruby 启用 batch 消费路径;否则降级为逐条 `channel.data`(V1.0 必须支持降级,便于调试)。
|
|
254
|
+
|
|
255
|
+
### 5.5 请求-响应配对
|
|
256
|
+
|
|
257
|
+
- 每个请求带 `id`(Ruby 单调递增),Erlang 必须回相同 `id` 的 result/error。
|
|
258
|
+
- Erlang 推送(notification)**无 id**。
|
|
259
|
+
- 单请求默认超时 **30s**;`conn.connect` 默认 **60s**(含跳板链);可在 params 中用 `timeout_ms` 覆盖。
|
|
260
|
+
- 超时后 Ruby 不重发;由业务层决定是否重连/重开通道。
|
|
261
|
+
- Erlang 不得对同一 id 重排:响应序与请求到达序一致(单连接内)。
|
|
262
|
+
|
|
263
|
+
### 5.6 错误码补充
|
|
264
|
+
|
|
265
|
+
HLD 已列 -32700~-32007。补充:
|
|
266
|
+
|
|
267
|
+
| 码 | 含义 | 触发 |
|
|
268
|
+
|----|------|------|
|
|
269
|
+
| -32000 | Unauthorized | hello 失败 / token 校验失败 |
|
|
270
|
+
| -32008 | Flow paused | channel 处于背压暂停时 send 被拒,Ruby 应缓存后重试 |
|
|
271
|
+
| -32009 | Not ready | 连接尚未 ready 时操作通道 |
|
|
272
|
+
| -32010 | Schema mismatch | 配置版本协议不兼容 |
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## 6 Erlang 核心引擎详细设计
|
|
277
|
+
|
|
278
|
+
### 6.1 监督树(修正后)
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
ssh_core_sup (one_for_one, intensity 5/60s)
|
|
282
|
+
├── ssh_infra_sup (one_for_one, intensity 5/60s)
|
|
283
|
+
│ ├── ssh_known_hosts_proxy (permanent, gen_server)
|
|
284
|
+
│ ├── ssh_keepalive_mgr (permanent, gen_server)
|
|
285
|
+
│ └── ssh_ipc_gateway (permanent, gen_server)
|
|
286
|
+
│ └── (内部持有 ssh_ipc_coalesce 进程,dynamic)
|
|
287
|
+
├── ssh_conn_sup (simple_one_for_one, intensity 100/60s, temporary)
|
|
288
|
+
│ └── [ssh_conn_worker × N]
|
|
289
|
+
│ └── [ssh_channel_stm × M] (conn_worker 直接 start_child 到独立临时 supervisor? 否——
|
|
290
|
+
│ channel 作为 conn_worker 的子进程,挂在 conn_worker 内部)
|
|
291
|
+
└── ssh_sftp_sup (simple_one_for_one, intensity 20/60s, temporary)
|
|
292
|
+
└── [ssh_sftp_session × K]
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
修正要点:
|
|
296
|
+
- ipc_gateway、keepalive、known_hosts 移入 `ssh_infra_sup`,与 `ssh_conn_sup` 隔离,单连接崩溃不波及基础设施。
|
|
297
|
+
- `conn_worker` 内部直接 `proc_lib:spawn_link` 起 `channel_stm`,不走独立 supervisor;channel 生命周期严格随父连接,父亡子亡。
|
|
298
|
+
- `sftp_session` 独立于 conn_worker(可在同连接shell同时跑 SFTP),由 `ssh_sftp_sup` 管理,但保存 `conn_id` 关联;连接断开时 conn_worker 通知 sftp_sup 清理同 conn_id 的会话。
|
|
299
|
+
|
|
300
|
+
### 6.2 ssh_ipc_gateway
|
|
301
|
+
|
|
302
|
+
```erlang
|
|
303
|
+
-module(ssh_ipc_gateway).
|
|
304
|
+
-behaviour(gen_server).
|
|
305
|
+
|
|
306
|
+
-export([start_link/1, push_event/2, push_batch/1]).
|
|
307
|
+
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
|
|
308
|
+
|
|
309
|
+
%% 公共 API(其他 Erlang 模块调用)
|
|
310
|
+
%% push_event/2:单条推送,经 coalesce 缓冲
|
|
311
|
+
%% push_batch/1:直接发送已合并批量(coalesce tick 调用)
|
|
312
|
+
|
|
313
|
+
-include("ssh_ipc.hrl").
|
|
314
|
+
|
|
315
|
+
-record(client, {
|
|
316
|
+
socket :: gen_tcp:socket() | port(),
|
|
317
|
+
authed = false :: boolean(),
|
|
318
|
+
pending = #{} :: #{rpc_id() => {erlang:timestamp(), From::term()}},
|
|
319
|
+
send_q = queue:new() :: queue:queue(binary()),
|
|
320
|
+
flow_blocked = false :: boolean()
|
|
321
|
+
}).
|
|
322
|
+
|
|
323
|
+
-record(state, {
|
|
324
|
+
listen_sock,
|
|
325
|
+
clients = #{} :: #{pid() | reference() => #client{}},
|
|
326
|
+
auth_token :: binary(),
|
|
327
|
+
routes :: #{binary() => {module(), atom()}},
|
|
328
|
+
coalesce_ref :: pid() | undefined
|
|
329
|
+
}).
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**关键函数**:
|
|
333
|
+
|
|
334
|
+
| 函数 | 签名 | 说明 |
|
|
335
|
+
|------|------|------|
|
|
336
|
+
| `start_link/1` | `(Opts) -> {ok, Pid}` | Opts 含 listen 端点、token、routes |
|
|
337
|
+
| `push_event/2` | `(Method, Params) -> ok` | 写入 coalesce buffer,由 coalesce tick 统一发送 |
|
|
338
|
+
| `handle_tcp_data/2` | (内部) | 解帧 → `ssh_ipc_proto:decode` → 路由 |
|
|
339
|
+
| `dispatch/3` | (内部) | spawn 执行 `{M,F}`,结果回写;崩溃包 error 响应 |
|
|
340
|
+
| `send_to_client/2` | (内部) | 发送编码后的 JSON;失败入 send_q;q 满(>1MB) 关闭该 client |
|
|
341
|
+
|
|
342
|
+
**路由表**(启动时注入,运行期不可变):
|
|
343
|
+
|
|
344
|
+
```erlang
|
|
345
|
+
%% init 中构建
|
|
346
|
+
Routes = #{
|
|
347
|
+
<<"hello">> => {?MODULE, handle_hello},
|
|
348
|
+
<<"bye">> => {?MODULE, handle_bye},
|
|
349
|
+
<<"engine.ping">> => {?MODULE, handle_ping},
|
|
350
|
+
<<"engine.stats">> => {ssh_engine_stats, get_stats},
|
|
351
|
+
<<"engine.shutdown">> => {?MODULE, handle_shutdown},
|
|
352
|
+
<<"conn.connect">> => {ssh_conn_sup, rpc_connect}, % 触发 start_child + do_connect
|
|
353
|
+
<<"conn.disconnect">> => {ssh_conn_worker, rpc_disconnect},
|
|
354
|
+
<<"conn.list">> => {ssh_conn_sup, rpc_list},
|
|
355
|
+
<<"conn.reconnect">> => {ssh_conn_worker, rpc_reconnect},
|
|
356
|
+
<<"channel.open">> => {ssh_conn_worker, rpc_channel_open},
|
|
357
|
+
<<"channel.send">> => {ssh_channel_stm, rpc_send},
|
|
358
|
+
<<"channel.close">> => {ssh_channel_stm, rpc_close},
|
|
359
|
+
<<"channel.window_change">> => {ssh_channel_stm, rpc_window_change},
|
|
360
|
+
<<"sftp.open">> => {ssh_sftp_sup, rpc_open},
|
|
361
|
+
<<"sftp.list_dir">> => {ssh_sftp_session, rpc_list_dir},
|
|
362
|
+
<<"sftp.download">> => {ssh_sftp_session, rpc_download},
|
|
363
|
+
<<"sftp.upload">> => {ssh_sftp_session, rpc_upload},
|
|
364
|
+
<<"sftp.mkdir">> => {ssh_sftp_session, rpc_mkdir},
|
|
365
|
+
<<"sftp.remove">> => {ssh_sftp_session, rpc_remove},
|
|
366
|
+
<<"sftp.stat">> => {ssh_sftp_session, rpc_stat},
|
|
367
|
+
<<"portfwd.add">> => {ssh_port_fwd, rpc_add},
|
|
368
|
+
<<"portfwd.remove">> => {ssh_port_fwd, rpc_remove},
|
|
369
|
+
<<"portfwd.list">> => {ssh_port_fwd, rpc_list},
|
|
370
|
+
<<"hostkey.resolve">> => {ssh_known_hosts_proxy, rpc_resolve} % Ruby→Erlang 不需要,这个方向是 Erlang→Ruby
|
|
371
|
+
}.
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
注意:`hostkey.resolve` 实际方向是 Erlang→Ruby(Erlang 主动发 notification 询问),不放 routes。Routes 仅处理 Ruby→Erlang 的同步 RPC。
|
|
375
|
+
|
|
376
|
+
### 6.3 ssh_conn_worker
|
|
377
|
+
|
|
378
|
+
```erlang
|
|
379
|
+
-module(ssh_conn_worker).
|
|
380
|
+
-behaviour(gen_statem).
|
|
381
|
+
|
|
382
|
+
%% 顶部 API
|
|
383
|
+
-export([start_link/1, rpc_disconnect/1, rpc_channel_open/2,
|
|
384
|
+
rpc_reconnect/1, get_state/1, conn_id/1]).
|
|
385
|
+
%% gen_statem 回调
|
|
386
|
+
-export([callback_mode/0, init/1]).
|
|
387
|
+
-export([idle/3, connecting/3, authenticating/3, ready/3,
|
|
388
|
+
reconnecting/3, closing/3, failed/3]).
|
|
389
|
+
|
|
390
|
+
-record(conn, {
|
|
391
|
+
id :: binary(),
|
|
392
|
+
spec :: map(),
|
|
393
|
+
ssh_ref :: reference() | undefined,
|
|
394
|
+
channels = #{} :: #{binary() => pid()},
|
|
395
|
+
next_ch_seq = 1 :: pos_integer(),
|
|
396
|
+
reconnect_count = 0 :: non_neg_integer(),
|
|
397
|
+
options :: [proplists:property()],
|
|
398
|
+
fail_reason :: term() | undefined,
|
|
399
|
+
proxy_sock_fun :: function() | undefined
|
|
400
|
+
}).
|
|
401
|
+
|
|
402
|
+
callback_mode() -> state_functions.
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
**状态机(修正后)**:
|
|
406
|
+
|
|
407
|
+
```
|
|
408
|
+
┌────────┐
|
|
409
|
+
│ idle │
|
|
410
|
+
└───┬────┘
|
|
411
|
+
do_connect │
|
|
412
|
+
┌────────────▼─────────────┐
|
|
413
|
+
│ connecting │ TCP/Unix 已连,等待 SSH banner+KEX
|
|
414
|
+
│ ssh:connect 成功 → authenticating
|
|
415
|
+
│ 失败 → failed{reason}
|
|
416
|
+
└────────────┬─────────────┘
|
|
417
|
+
│ otp ssh 在 connect 阶段一并完成认证
|
|
418
|
+
┌────────────▼─────────────┐
|
|
419
|
+
│ authenticating │ (仅当 key_cb 需要 prompt 时短暂停留)
|
|
420
|
+
│ auth 完成且 ok → ready
|
|
421
|
+
│ auth 失败 → failed{auth}
|
|
422
|
+
└────────────┬─────────────┘
|
|
423
|
+
│
|
|
424
|
+
┌────────────▼─────────────┐
|
|
425
|
+
│ ready │ 可开通道
|
|
426
|
+
│ 网络断开/ssh 断开 → reconnecting
|
|
427
|
+
│ disconnect → closing
|
|
428
|
+
└─────┬───────────────┬────┘
|
|
429
|
+
│ reconnect │
|
|
430
|
+
┌─────▼─────┐ ┌────▼─────┐
|
|
431
|
+
│reconnecting│ │ closing │
|
|
432
|
+
│ 成功→ready │ │ → closed │
|
|
433
|
+
│ 最大→failed│ └──────────┘
|
|
434
|
+
└───────────┘
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
**修正后的连接建立**(E1 修正):
|
|
438
|
+
|
|
439
|
+
```erlang
|
|
440
|
+
%% connecting 状态
|
|
441
|
+
connecting(enter, _Old, Data) ->
|
|
442
|
+
{keep_state, Data};
|
|
443
|
+
connecting({call, From}, do_connect, #conn{spec=Spec, id=Id} = Data) ->
|
|
444
|
+
Host = binary_to_list(maps:get(<<"host">>, Spec)),
|
|
445
|
+
Port = maps:get(<<"port">>, Spec, 22),
|
|
446
|
+
User = binary_to_list(maps:get(<<"user">>, Spec)),
|
|
447
|
+
Options = build_ssh_options(Spec),
|
|
448
|
+
%% 关键修正:认证参数进 options,不存在 ssh:auth_user
|
|
449
|
+
case ssh:connect(Host, Port, Options, ?CONNECT_TIMEOUT_MS) of
|
|
450
|
+
{ok, SshRef} ->
|
|
451
|
+
%% ssh:connect 成功即表示认证通过(otp ssh 把 auth 作为 connect 的一部分)
|
|
452
|
+
Fingerprint = get_host_fingerprint(SshRef),
|
|
453
|
+
push_conn_ready(Id, Fingerprint),
|
|
454
|
+
{next_state, ready, Data#conn{ssh_ref=SshRef}, [{reply, From, {ok, Id}}]};
|
|
455
|
+
{error, Reason} ->
|
|
456
|
+
push_conn_failed(Id, Reason),
|
|
457
|
+
{next_state, failed, Data#conn{fail_reason=Reason},
|
|
458
|
+
[{reply, From, {error, Reason}}]}
|
|
459
|
+
end.
|
|
460
|
+
|
|
461
|
+
build_ssh_options(Spec) ->
|
|
462
|
+
[{user, binary_to_list(maps:get(<<"user">>, Spec))},
|
|
463
|
+
{silently_accept_hosts, false},
|
|
464
|
+
{key_cb, {ssh_auth_engine, maps:get(<<"auth">>, Spec, #{})}},
|
|
465
|
+
{user_dir, binary_to_list(maps:get(<<"key_dir">>, Spec, <<"/tmp">>))},
|
|
466
|
+
{preferred_algorithms, preferred_algs()},
|
|
467
|
+
{connect_timeout, ?CONNECT_TIMEOUT_MS},
|
|
468
|
+
{user_interaction, true}]
|
|
469
|
+
++ build_auth_options(Spec)
|
|
470
|
+
++ build_proxy_options(maps:get(<<"proxy">>, Spec, undefined))
|
|
471
|
+
++ build_jump_options(maps:get(<<"jumps">>, Spec, [])).
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
**认证选项构造**(E1/E2 修正):
|
|
475
|
+
|
|
476
|
+
```erlang
|
|
477
|
+
build_auth_options(#{<<"auth">> := Auth} = _Spec) ->
|
|
478
|
+
Type = maps:get(<<"type">>, Auth, password),
|
|
479
|
+
case Type of
|
|
480
|
+
password ->
|
|
481
|
+
[{password, binary_to_list(maps:get(<<"password">>, Auth))}];
|
|
482
|
+
publickey ->
|
|
483
|
+
%% key_cb 回调负责 load + 依次尝试多密钥
|
|
484
|
+
[]; % 实际密钥加载由 ssh_auth_engine:key_cb 处理
|
|
485
|
+
keyboard_interactive ->
|
|
486
|
+
[{keyboard_interactive, fun(Prompts, _Ssh) -> handle_prompts(Prompts) end}]
|
|
487
|
+
end;
|
|
488
|
+
build_auth_options(_) -> [].
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
**跳板选项**(E3/E6 修正):见 6.7。
|
|
492
|
+
|
|
493
|
+
### 6.4 ssh_auth_engine(key_cb 实现)
|
|
494
|
+
|
|
495
|
+
OTP ssh 通过 `key_cb` 回调完成公钥认证,而非 `ssh:auth_user`。
|
|
496
|
+
|
|
497
|
+
```erlang
|
|
498
|
+
-module(ssh_auth_engine).
|
|
499
|
+
-behaviour(ssh_client_key_api). % OTP ssh 公钥回调 behaviour
|
|
500
|
+
|
|
501
|
+
-export([add_host_key/3, is_host_key/5, % 主机密钥回调(key_cb)
|
|
502
|
+
user_key/3]). % 用户私钥回调
|
|
503
|
+
-export([authenticate_chain/2]). % 高层:认证回退链
|
|
504
|
+
|
|
505
|
+
%% @doc 添加主机密钥(首次连接)
|
|
506
|
+
%% OTP ssh 调用,要求我们裁决;不在此落盘
|
|
507
|
+
add_host_key(_Host, _Port, PublicKey) ->
|
|
508
|
+
%% 统一交给 ssh_known_hosts_proxy 经 IPC 问 Ruby
|
|
509
|
+
case ssh_known_hosts_proxy:verify(Host, Port, PublicKey) of
|
|
510
|
+
accepted -> ok;
|
|
511
|
+
rejected -> {error, host_key_rejected}
|
|
512
|
+
end.
|
|
513
|
+
|
|
514
|
+
%% @doc 校验已有主机密钥
|
|
515
|
+
is_host_key(Key, _Host, _Port, _Algorithm, _) ->
|
|
516
|
+
case ssh_known_hosts_proxy:verify(_Host, _Port, Key) of
|
|
517
|
+
accepted -> true;
|
|
518
|
+
rejected -> false
|
|
519
|
+
end.
|
|
520
|
+
|
|
521
|
+
%% @doc 读取用户私钥,OTP ssh 调用此函数进行公钥认证
|
|
522
|
+
user_key(Algorithm, _Opts) ->
|
|
523
|
+
%% Algorithm: 'ssh-rsa' | 'ssh-ed25519' | 'ecdsa-sha2-nistp256' ...
|
|
524
|
+
%% 从 key_cb 初始化时传入的 Auth map 读取对应私钥路径
|
|
525
|
+
KeyInfo = get(key_keyinfo), % process dict,init 时设置
|
|
526
|
+
Path = maps:get(path, KeyInfo),
|
|
527
|
+
Passphrase = maps:get(passphrase, KeyInfo, undefined),
|
|
528
|
+
read_private_key(Path, Algorithm, Passphrase).
|
|
529
|
+
|
|
530
|
+
read_private_key(Path, Algorithm, Passphrase) ->
|
|
531
|
+
%% 使用 public_key:read_keyfile/2 读取 OpenSSH/PKCS8/SEC1
|
|
532
|
+
case public_key:read_keyfile(Path, [{passphrase, Passphrase}, {algorithm, Algorithm}]) of
|
|
533
|
+
{ok, Key} -> {ok, Key};
|
|
534
|
+
{error, _} = E -> E
|
|
535
|
+
end.
|
|
536
|
+
|
|
537
|
+
%% @doc 认证回退链:OTP ssh 单次 connect 只支持一种 auth_method,
|
|
538
|
+
%% 回退需多次 connect 或用 keyboard_interactive 多轮。
|
|
539
|
+
%% V1.0 实现:按链顺序逐次 ssh:connect。
|
|
540
|
+
%% 返回 {ok, SshRef} | {error, Reason}
|
|
541
|
+
authenticate_chain(ConnectCtx, [Method | Rest]) ->
|
|
542
|
+
Opts = build_opts_for_method(ConnectCtx, Method),
|
|
543
|
+
case ssh:connect(host_of(ConnectCtx), port_of(ConnectCtx), Opts, ?T) of
|
|
544
|
+
{ok, Ref} -> {ok, Ref};
|
|
545
|
+
{error, _} -> authenticate_chain(ConnectCtx, Rest)
|
|
546
|
+
end;
|
|
547
|
+
authenticate_chain(_, []) -> {error, all_auth_methods_failed}.
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
**关键修正**:OTP ssh 把认证和连接耦合在 `ssh:connect` 里,多方式回退需要多次 connect(或用 keyboard_interactive 的多轮 response)。V1.0 简化:支持单一方式 + 公钥优先失败回退密码,回退通过重新 `ssh:connect` 实现,代价是多一次握手。
|
|
551
|
+
|
|
552
|
+
### 6.5 ssh_channel_stm
|
|
553
|
+
|
|
554
|
+
```erlang
|
|
555
|
+
-module(ssh_channel_stm).
|
|
556
|
+
-behaviour(gen_statem).
|
|
557
|
+
|
|
558
|
+
-export([start_link/4, rpc_send/2, rpc_close/1, rpc_window_change/3,
|
|
559
|
+
feed_data/2, notify_eof/2]).
|
|
560
|
+
-export([callback_mode/0, init/1]).
|
|
561
|
+
-export([opening/3, ready/3, flowing/3, eof/3, closed/3]).
|
|
562
|
+
|
|
563
|
+
-record(ch, {
|
|
564
|
+
id :: binary(),
|
|
565
|
+
conn_id :: binary(),
|
|
566
|
+
ssh_ref :: reference(),
|
|
567
|
+
ssh_chan_id :: integer(), % otp ssh 内部通道号
|
|
568
|
+
type :: shell | exec | subsystem,
|
|
569
|
+
term_type :: binary() | undefined,
|
|
570
|
+
cols :: pos_integer(),
|
|
571
|
+
rows :: pos_integer(),
|
|
572
|
+
coalesce_ref :: pid()
|
|
573
|
+
}).
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
**状态机**:
|
|
577
|
+
|
|
578
|
+
```
|
|
579
|
+
opening ← start_link 后立即 ssh_connection:session_channel
|
|
580
|
+
│ 成功 → ready
|
|
581
|
+
│ 失败 → closed{reason}
|
|
582
|
+
ready ← 通道建立,shell/exec 已发起
|
|
583
|
+
│ {data, Data} → flowing + push
|
|
584
|
+
│ close → closing
|
|
585
|
+
flowing ← 持续数据流
|
|
586
|
+
│ {data, Data} → push(滞留 flowing)
|
|
587
|
+
│ eof → eof
|
|
588
|
+
│ close → closing
|
|
589
|
+
eof ← 收到 EOF,仍可发少量数据
|
|
590
|
+
│ close → closed
|
|
591
|
+
closing → closed
|
|
592
|
+
closed ← 通知 Ruby channel.eof,进程退出
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
**数据推送(E9 修正)**:
|
|
596
|
+
|
|
597
|
+
```erlang
|
|
598
|
+
%% flowing/3 收到 SSH 层数据
|
|
599
|
+
flowing(info, {ssh_cm, _Ref, {data, ChanId, _Type, Payload}}, #ch{} = D) ->
|
|
600
|
+
%% 不直接 gateway:push_event,写入 coalesce buffer
|
|
601
|
+
ssh_ipc_coalesce:enqueue(D#ch.coalesce_ref, D#ch.id, Payload),
|
|
602
|
+
{next_state, flowing, D}.
|
|
603
|
+
|
|
604
|
+
%% rpc_send:Ruby 发数据到通道
|
|
605
|
+
rpc_send(ChId, Data) ->
|
|
606
|
+
%% gen_statem:call 寻址
|
|
607
|
+
case whereis({?MODULE, ChId}) of
|
|
608
|
+
P when is_pid(P) -> gen_statem:call(P, {send, Data});
|
|
609
|
+
undefined -> {error, not_found}
|
|
610
|
+
end.
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
通道进程注册:`register({via, Registry, {?MODULE, ChId}}, Pid)`,Ruby 经 `(conn_id, ch_id)` 复合寻址。
|
|
614
|
+
|
|
615
|
+
### 6.6 ssh_sftp_session
|
|
616
|
+
|
|
617
|
+
```erlang
|
|
618
|
+
-module(ssh_sftp_session).
|
|
619
|
+
-behaviour(gen_server).
|
|
620
|
+
|
|
621
|
+
-export([start_link/2, rpc_list_dir/2, rpc_download/3, rpc_upload/3,
|
|
622
|
+
rpc_mkdir/2, rpc_remove/2, rpc_stat/2]).
|
|
623
|
+
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
|
624
|
+
-include_lib("kernel/include/file.hrl").
|
|
625
|
+
|
|
626
|
+
-record(sftp, {
|
|
627
|
+
id :: binary(),
|
|
628
|
+
conn_id :: binary(),
|
|
629
|
+
ssh_ref :: reference(),
|
|
630
|
+
sftp_pid :: pid() % ssh_sftp:start_channel 返回的 pid
|
|
631
|
+
}).
|
|
632
|
+
|
|
633
|
+
%% open
|
|
634
|
+
start_link(ConnId, SshRef) ->
|
|
635
|
+
%% ssh_sftp:start_channel/1,2 在已有 ssh_ref 上开 SFTP 通道
|
|
636
|
+
{ok, SftpPid} = ssh_sftp:start_channel(SshRef, [{window, 10}, {packet, 32768}]),
|
|
637
|
+
Id = gen_sftp_id(ConnId),
|
|
638
|
+
gen_server:start_link({via, Registry, {?MODULE, Id}}, ?MODULE,
|
|
639
|
+
#{id=>Id, conn_id=>ConnId, ssh_ref=>SshRef, sftp_pid=>SftpPid}, []).
|
|
640
|
+
|
|
641
|
+
rpc_list_dir(Id, Path) ->
|
|
642
|
+
gen_server:call(via(Id), {list_dir, Path}).
|
|
643
|
+
|
|
644
|
+
handle_call({list_dir, Path}, _From, #sftp{sftp_pid=P} = S) ->
|
|
645
|
+
case ssh_sftp:read_file_info_all(P, Path) of
|
|
646
|
+
{ok, Entries} -> {reply, {ok, format_entries(Entries)}, S};
|
|
647
|
+
{error, E} -> {reply, {error, E}, S}
|
|
648
|
+
end.
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
**并发**:单 SFTP 会话单进程串行执行操作,避免 ssh_sftp 通道内部状态竞争。批量传输通过开多个 sftp_session(同一 conn 下)实现并发。
|
|
652
|
+
|
|
653
|
+
### 6.7 ssh_jump_chain(跳板链,E3/E6 修正)
|
|
654
|
+
|
|
655
|
+
OTP ssh 没有"在已有通道上叠 SSH"的直接 API。V1.0 采用**自定义 sock_fun**方案:
|
|
656
|
+
|
|
657
|
+
```erlang
|
|
658
|
+
-module(ssh_jump_chain).
|
|
659
|
+
|
|
660
|
+
%% @doc 构建多级跳板连接
|
|
661
|
+
%% Jumps = [J1, J2, ...] 从近到远
|
|
662
|
+
%% 算法:
|
|
663
|
+
%% 1. J1 直接 ssh:connect,拿到 Ref1
|
|
664
|
+
%% 2. 对 J2..Jn,每次在前一跳上开 direct_tcpip 通道,把底层 socket "借出",
|
|
665
|
+
%% 用 ssh:connect 的 {sock_fun, Fun} 选项让 otp ssh 在该 socket 上握手
|
|
666
|
+
%% 3. 最终在 Jn 上 direct_tcpip 到目标,再 ssh:connect 到目标
|
|
667
|
+
-spec build(connect_spec(), [connect_spec()]) -> {ok, reference()} | {error, term()}.
|
|
668
|
+
build(TargetSpec, []) ->
|
|
669
|
+
%% 无跳板,直接连
|
|
670
|
+
direct_connect(TargetSpec);
|
|
671
|
+
build(TargetSpec, Jumps) ->
|
|
672
|
+
case lists:foldl(fun(Jump, {ok, PrevRef}) ->
|
|
673
|
+
tunneled_connect(PrevRef, Jump);
|
|
674
|
+
(_, {error,_}=E) -> E
|
|
675
|
+
end, direct_connect(hd(Jumps)), tl(Jumps)) of
|
|
676
|
+
{ok, LastJumpRef} -> tunneled_connect(LastJumpRef, TargetSpec);
|
|
677
|
+
{error,_}=E -> E
|
|
678
|
+
end.
|
|
679
|
+
|
|
680
|
+
%% 直接 SSH 连接
|
|
681
|
+
direct_connect(Spec) ->
|
|
682
|
+
ssh:connect(host(Spec), port(Spec), build_ssh_options(Spec), ?T).
|
|
683
|
+
|
|
684
|
+
%% 在 PrevRef 的 SSH 隧道内连到下一跳的 TCP
|
|
685
|
+
tunneled_connect(PrevRef, Spec) ->
|
|
686
|
+
Host = host(Spec), Port = port(Spec),
|
|
687
|
+
%% otp ssh: ssh_connection:direct_tcpip(Conn, Host, Port, 0) 返回 channel
|
|
688
|
+
{ok, Chan} = ssh_connection:direct_tcpip(PrevRef, Host, Port, "127.0.0.1", 0, ?T),
|
|
689
|
+
%% 借出底层 socket:otp ssh 不直接暴露 socket,但提供 ssh_connection:channel_callback?
|
|
690
|
+
%% —— 实际实现:用 ssh_connection:send/recv 在 channel 上跑 SSH 握手,
|
|
691
|
+
%% 通过自定义 transport module 包装 channel 为 socket-like
|
|
692
|
+
SockFun = make_sock_fun_from_channel(PrevRef, Chan),
|
|
693
|
+
Opts = build_ssh_options(Spec) ++ [{sock_fun, SockFun}],
|
|
694
|
+
ssh:connect(Host, Port, Opts, ?T).
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
**关键风险与降级**:
|
|
698
|
+
- `{sock_fun, Fun}` 在 OTP 26 仍是半官方选项,签名 `Fun(connect, Host, Port, Timeout) -> {ok, Sock} | {error,_}`,`Fun(close, Sock) -> ok`,`Fun(send, Sock, Data) -> ok | {error,_}`,`Fun(recv, Sock, Len, Timeout) -> {ok, Data} | {error,_}`。
|
|
699
|
+
- 由于 `direct_tcpip` 返回 channel 而非裸 socket,需把 channel 包装成上述 SockFun 语义。**这是 V1.0 最高风险点**,建议 V1.0 先实现单跳跳板(ProxyJump 1 级),多跳作为 V2.0 验证后的能力。
|
|
700
|
+
- 降级方案:客户端侧用本地端口转发链模拟跳板(在本地开 listener,经 ssh 本地转发到 J1,再连 listener),牺牲配置一致性换取实现可靠。
|
|
701
|
+
|
|
702
|
+
### 6.8 ssh_port_fwd(E4 修正)
|
|
703
|
+
|
|
704
|
+
| 类型 | OTP ssh API | 说明 |
|
|
705
|
+
|------|------------|------|
|
|
706
|
+
| Local | 自建 `gen_tcp:listen` + `ssh_connection:direct_tcpip` | `ssh_connection:direct_tcpip/6` 在 ssh 连接上开到目标的 channel |
|
|
707
|
+
| Remote | `ssh_connection:tcpip_forward/3` | 请求 sshd 监听远端端口,远端有连接时 sshd 回调 |
|
|
708
|
+
| Dynamic | Local + 自实现 SOCKS5 协商 | 读 SOCKS5 请求,解析目标后 `direct_tcpip` |
|
|
709
|
+
|
|
710
|
+
V1.0 必交付 Local + Remote,Dynamic 列为 V2.0。
|
|
711
|
+
|
|
712
|
+
### 6.9 ssh_keepalive_mgr
|
|
713
|
+
|
|
714
|
+
周期巡检所有 `ssh_conn_worker`,连续 3 次失败触发 reconnect。重连策略见 HLD(指数退避)。
|
|
715
|
+
|
|
716
|
+
**修正**:keepalive 不直接 `ssh_conn_worker:trigger_reconnect`,而是发 `conn.closed` 事件给 Ruby,由 Ruby 决定是否重连。Erlang 只负责"判定断开并通知",重连由业务层触发 `conn.reconnect`。这避免 Erlang 自主重连冲击服务器且 Ruby 状态不同步。
|
|
717
|
+
|
|
718
|
+
### 6.10 ssh_known_hosts_proxy(E7 修正)
|
|
719
|
+
|
|
720
|
+
```erlang
|
|
721
|
+
-module(ssh_known_hosts_proxy).
|
|
722
|
+
-behaviour(gen_server).
|
|
723
|
+
|
|
724
|
+
%% OTP ssh key_cb 调用此模块裁决主机密钥
|
|
725
|
+
%% 本模块不落盘,经 IPC 向 Ruby 查询
|
|
726
|
+
|
|
727
|
+
-export([verify/3, start_link/0]).
|
|
728
|
+
-export([init/1, handle_call/3, handle_info/2]).
|
|
729
|
+
|
|
730
|
+
-record(st, {}).
|
|
731
|
+
|
|
732
|
+
%% @doc 由 ssh_auth_engine:add_host_key/is_host_key 调用
|
|
733
|
+
verify(Host, Port, Key) ->
|
|
734
|
+
Fingerprint = pubkey_fingerprint(Key),
|
|
735
|
+
%% 通过 gateway 向 Ruby 发起 notification(非 RPC,因 key_cb 可能同步等待)
|
|
736
|
+
%% 这里用同步 IPC 请求-响应:发一个 method="hostkey.resolve" 的请求
|
|
737
|
+
Req = #{method => <<"hostkey.resolve">>,
|
|
738
|
+
params => #{host=>Host, port=>Port, fingerprint=>Fingerprint}},
|
|
739
|
+
%% 注:此调用线程阻塞直到 Ruby 响应
|
|
740
|
+
case ssh_ipc_gateway:synchronous_push(Req, 30000) of
|
|
741
|
+
{ok, #{action := <<"accept">>}} -> accepted;
|
|
742
|
+
{ok, #{action := <<"once">>}} -> accepted_once;
|
|
743
|
+
{ok, #{action := <<"reject">>}} -> rejected;
|
|
744
|
+
{error, timeout} -> rejected
|
|
745
|
+
end.
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
**并发隐患**:`synchronous_push` 会阻塞 OTP ssh 的 connect 调用进程;若 Ruby 不响应,30s 后 reject。V1.0 接受这个延迟。Ruby 侧必须保证 `hostkey.resolve` 处理快速(通常是本地查 known_hosts + 可能弹窗,弹窗超时 30s 自动 reject)。
|
|
749
|
+
|
|
750
|
+
---
|
|
751
|
+
|
|
752
|
+
## 7 Ruby 调度层详细设计
|
|
753
|
+
|
|
754
|
+
### 7.1 线程模型(修正 E8)
|
|
755
|
+
|
|
756
|
+
```
|
|
757
|
+
Ruby 进程
|
|
758
|
+
├── main thread
|
|
759
|
+
│ └── 所有业务操作、Vault、Session::Manager
|
|
760
|
+
│ IPC::Router 持有 @pending 表(Mutex 保护)
|
|
761
|
+
├── reader thread (1 个)
|
|
762
|
+
│ └── 读 socket → 按 \n 切帧 → 解析 JSON
|
|
763
|
+
│ {id, result} → 推入对应 Queue
|
|
764
|
+
│ {method} → 推入 event_queue
|
|
765
|
+
├── event dispatcher thread (1 个)
|
|
766
|
+
│ └── 从 event_queue 取推送
|
|
767
|
+
│ channel.data.batch → 拆 batch → 按 channel_id 路由到对应 Terminal
|
|
768
|
+
│ channel.eof / conn.closed → 推 main thread 的 pending actions 或回调
|
|
769
|
+
│ hostkey.resolve → 推 Security::HostKey 处理
|
|
770
|
+
└── (不创建其他常驻线程;BatchExec 临时线程在 V2.0 由 Celluloid/线程池替代)
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
**关键约定**:
|
|
774
|
+
- reader thread 只解析不回调,避免阻塞读取。
|
|
775
|
+
- event dispatcher 对每个 channel 的数据按序处理(`Channel#on_data` 加Mutex)。
|
|
776
|
+
- `IPC::Router#call` 在 main thread 调用,阻塞等待 reader thread 写入 Queue。
|
|
777
|
+
|
|
778
|
+
### 7.2 IPC::Router(取代 HLD 的 IPC::Client)
|
|
779
|
+
|
|
780
|
+
```ruby
|
|
781
|
+
module NetworkInfraUtility
|
|
782
|
+
module SSH
|
|
783
|
+
module IPC
|
|
784
|
+
class Router
|
|
785
|
+
def initialize(transport)
|
|
786
|
+
@transport = transport
|
|
787
|
+
@id_mutex = Mutex.new
|
|
788
|
+
@next_id = 0
|
|
789
|
+
@pending = {} # id => [Queue, deadline]
|
|
790
|
+
@pending_mutex = Mutex.new
|
|
791
|
+
@subscriptions = {} # method => [Proc]
|
|
792
|
+
@subscriptions_mutex = Mutex.new
|
|
793
|
+
@closed = false
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
# 同步调用
|
|
797
|
+
def call(method, params = {}, timeout_ms: 30_000)
|
|
798
|
+
id = next_id
|
|
799
|
+
q = Queue.new
|
|
800
|
+
register_pending(id, q, timeout_ms)
|
|
801
|
+
send({jsonrpc: "2.0", id: id, method: method, params: params})
|
|
802
|
+
msg = q.pop(timeout: timeout_ms / 1000.0)
|
|
803
|
+
raise RPCTimeout, method unless msg
|
|
804
|
+
raise RPCError.new(msg[:error]) if msg[:error]
|
|
805
|
+
msg[:result]
|
|
806
|
+
ensure
|
|
807
|
+
unregister_pending(id)
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
# 订阅推送,返回 subscription_id 用于注销
|
|
811
|
+
def subscribe(method, &block)
|
|
812
|
+
sid = SecureRandom.hex(8)
|
|
813
|
+
@subscriptions_mutex.synchronize do
|
|
814
|
+
(@subscriptions[method] ||= {})[sid] = block
|
|
815
|
+
end
|
|
816
|
+
sid
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
def unsubscribe(method, sid)
|
|
820
|
+
@subscriptions_mutex.synchronize do
|
|
821
|
+
@subscriptions[method]&.delete(sid)
|
|
822
|
+
end
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
# 由 reader thread 调用
|
|
826
|
+
def on_message(msg)
|
|
827
|
+
if msg[:id]
|
|
828
|
+
# 响应
|
|
829
|
+
q = pop_pending(msg[:id])
|
|
830
|
+
q << msg if q
|
|
831
|
+
elsif msg[:method]
|
|
832
|
+
# 推送
|
|
833
|
+
dispatch_push(msg[:method], msg[:params])
|
|
834
|
+
end
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
private
|
|
838
|
+
|
|
839
|
+
def dispatch_push(method, params)
|
|
840
|
+
callbacks = @subscriptions_mutex.synchronize { @subscriptions[method]&.values&.dup }
|
|
841
|
+
callbacks&.each { |cb| cb.call(params) }
|
|
842
|
+
end
|
|
843
|
+
end
|
|
844
|
+
end
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
**修正点**:
|
|
850
|
+
- 订阅返回 `sid`,可注销,解决 HLD 永久回调泄漏。
|
|
851
|
+
- `@pending` 用 Mutex 保护,reader thread 与 main thread 安全。
|
|
852
|
+
- 超时后主动 `unregister_pending`,reader 若晚到则丢弃。
|
|
853
|
+
|
|
854
|
+
### 7.3 IPC::Transport
|
|
855
|
+
|
|
856
|
+
```ruby
|
|
857
|
+
class Transport
|
|
858
|
+
def initialize(endpoint)
|
|
859
|
+
@socket = open_socket(endpoint)
|
|
860
|
+
@write_mutex = Mutex.new
|
|
861
|
+
@reader_t = nil
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
# 在 reader thread 中循环调用
|
|
865
|
+
def each_frame
|
|
866
|
+
buffer = +""
|
|
867
|
+
loop do
|
|
868
|
+
chunk = @socket.readpartial(65536)
|
|
869
|
+
buffer << chunk
|
|
870
|
+
while idx = buffer.index("\n")
|
|
871
|
+
yield buffer[0...idx]
|
|
872
|
+
buffer = buffer[(idx+1)..]
|
|
873
|
+
end
|
|
874
|
+
end
|
|
875
|
+
rescue EOFError, SystemCallError
|
|
876
|
+
@closed = true
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
def send(hash)
|
|
880
|
+
data = JSON.generate(hash) + "\n"
|
|
881
|
+
@write_mutex.synchronize { @socket.write(data) }
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
注意:`@socket.readpartial` 遇到 EOF 抛 `EOFError`,需 rescue 后通知 Router 关闭。Ruby TCPSocket/UNIXSocket 都支持 `readpartial`。
|
|
887
|
+
|
|
888
|
+
### 7.4 IPC::Coalesce(push 消费端)
|
|
889
|
+
|
|
890
|
+
```ruby
|
|
891
|
+
class Coalesce
|
|
892
|
+
def initialize(router)
|
|
893
|
+
@router = router
|
|
894
|
+
@terminals = {} # (conn_id, ch_id) => Terminal::Emulator
|
|
895
|
+
@terminals_mutex = Mutex.new
|
|
896
|
+
@default_q = Queue.new # 非 batch 的单条 channel.data
|
|
897
|
+
setup_subscriptions
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def register_channel(conn_id, ch_id, terminal)
|
|
901
|
+
@terminals_mutex.synchronize { @terminals[[conn_id, ch_id]] = terminal }
|
|
902
|
+
terminal.coalesce_sid = @router.subscribe("channel.data") do |p|
|
|
903
|
+
handler_single(p)
|
|
904
|
+
end
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def unregister_channel(conn_id, ch_id)
|
|
908
|
+
@terminals_mutex.synchronize { @terminals.delete([conn_id, ch_id]) }
|
|
909
|
+
# 注销单个 subscription 由 terminal 持有 sid 自行 unsubscribe
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
private
|
|
913
|
+
|
|
914
|
+
def setup_subscriptions
|
|
915
|
+
@router.subscribe("channel.data.batch") do |p|
|
|
916
|
+
items = p[:items] || []
|
|
917
|
+
items.each do |item|
|
|
918
|
+
dispatch_data(item[:id], item[:data])
|
|
919
|
+
end
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
def dispatch_data(ch_id, b64)
|
|
924
|
+
data = Base64.decode64(b64)
|
|
925
|
+
term = @terminals_mutex.synchronize { @terminals.values.find { |t| t.channel_id == ch_id } }
|
|
926
|
+
# 注:匹配规则用 (conn_id, ch_id),上面简化
|
|
927
|
+
term&.feed(data)
|
|
928
|
+
end
|
|
929
|
+
end
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
### 7.5 Session::Session(修正)
|
|
933
|
+
|
|
934
|
+
```ruby
|
|
935
|
+
class Session
|
|
936
|
+
attr_reader :session_id, :conn_id, :name, :terminal, :file_manager
|
|
937
|
+
|
|
938
|
+
def initialize(client, session_id, conn_id, spec)
|
|
939
|
+
@client = client
|
|
940
|
+
@session_id = session_id
|
|
941
|
+
@conn_id = conn_id
|
|
942
|
+
@spec = spec
|
|
943
|
+
@terminal = nil
|
|
944
|
+
@file_manager = nil
|
|
945
|
+
@status = :connected
|
|
946
|
+
@coalesce = client.coalesce
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
def open_terminal(term_type: "xterm-256color", cols: 80, rows: 24)
|
|
950
|
+
raise AlreadyOpen if @terminal
|
|
951
|
+
resp = @client.ipc.call("channel.open",
|
|
952
|
+
{conn_id: @conn_id, type: "shell", term: term_type, cols: cols, rows: rows})
|
|
953
|
+
ch_id = resp[:channel_id]
|
|
954
|
+
@terminal = Terminal::Emulator.new(@client, @conn_id, ch_id, cols, rows, theme: @spec[:theme])
|
|
955
|
+
@coalesce.register_channel(@conn_id, ch_id, @terminal)
|
|
956
|
+
@terminal
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
def close_terminal
|
|
960
|
+
return unless @terminal
|
|
961
|
+
@client.ipc.call("channel.close", {id: @terminal.channel_id})
|
|
962
|
+
@coalesce.unregister_channel(@conn_id, @terminal.channel_id)
|
|
963
|
+
@terminal = nil
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
def disconnect
|
|
967
|
+
@client.ipc.call("conn.disconnect", {id: @conn_id})
|
|
968
|
+
@terminal = nil
|
|
969
|
+
@file_manager = nil
|
|
970
|
+
@status = :disconnected
|
|
971
|
+
end
|
|
972
|
+
end
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
### 7.6 Terminal::Emulator / Screen / Buffer
|
|
976
|
+
|
|
977
|
+
三层分离,避免 HLD 把所有塞进 Emulator:
|
|
978
|
+
|
|
979
|
+
- `Emulator`:ANSI/xterm 解析器,喂入字节,调用 `Screen` 的方法。
|
|
980
|
+
- `Screen`:屏幕状态(行数组 + 光标 + 滚动区 + alt buffer + 字符属性),可被 Renderer 读取。
|
|
981
|
+
- `Buffer`:回滚区 + 当前屏幕的快照,提供 search/export,写入由 `Screen` 触发。
|
|
982
|
+
|
|
983
|
+
```ruby
|
|
984
|
+
class Emulator
|
|
985
|
+
def initialize(client, conn_id, ch_id, cols, rows, theme:)
|
|
986
|
+
@client = client
|
|
987
|
+
@conn_id = conn_id
|
|
988
|
+
@channel_id = ch_id
|
|
989
|
+
@screen = Screen.new(cols, rows)
|
|
990
|
+
@buffer = Buffer.new(max_lines: 10_000)
|
|
991
|
+
@buffer.attach_screen(@screen)
|
|
992
|
+
@theme = Theme.load(theme)
|
|
993
|
+
@parser = AnsiParser.new(self)
|
|
994
|
+
@logger = nil
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
attr_reader :channel_id, :screen, :buffer
|
|
998
|
+
|
|
999
|
+
def feed(data)
|
|
1000
|
+
@parser.feed(data)
|
|
1001
|
+
@logger&.write(data)
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
def send(data)
|
|
1005
|
+
@client.ipc.call("channel.send", {id: @channel_id, data: Base64.encode64(data)})
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
def resize(cols, rows)
|
|
1009
|
+
@screen.resize(cols, rows)
|
|
1010
|
+
@client.ipc.call("channel.window_change", {id: @channel_id, cols: cols, rows: rows})
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
# 由 AnsiParser 回调
|
|
1014
|
+
def put_char(ch, x, y, style); @screen.put_char(ch, x, y, style); end
|
|
1015
|
+
def cursor_to(x, y); @screen.cursor_to(x, y); end
|
|
1016
|
+
def newline; @screen.newline; end
|
|
1017
|
+
# ... 其余 CSI/SGR 回调
|
|
1018
|
+
end
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
`AnsiParser` 是纯解析器,无状态副作用,只调 `Emulator` 回调。这是 V1.0 工作量较大的模块,建议参考 `vte`/`tty`/`ruby-vte` 的转义表,覆盖 ≥ 95% ANSI(FR-TERM-001 ④)。
|
|
1022
|
+
|
|
1023
|
+
### 7.7 Security::Vault(修正并发)
|
|
1024
|
+
|
|
1025
|
+
HLD 的 Vault 实现基本可用,修正:
|
|
1026
|
+
- `@cache` 不在多线程访问(V1.0 主线程唯一),但为 V2.0 batch 预留 `@cache_mutex`。
|
|
1027
|
+
- `resolve_credentials` 不就地修改 spec(mutation),返回新 hash,避免调用方副作用。
|
|
1028
|
+
|
|
1029
|
+
### 7.8 Security::HostKey
|
|
1030
|
+
|
|
1031
|
+
```ruby
|
|
1032
|
+
module Security
|
|
1033
|
+
class HostKey
|
|
1034
|
+
def initialize(store_path)
|
|
1035
|
+
@path = store_path
|
|
1036
|
+
@entries = load
|
|
1037
|
+
@save_mutex = Mutex.new
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
# 由 IPC 推送 hostkey.resolve 触发
|
|
1041
|
+
def resolve(host, port, fingerprint)
|
|
1042
|
+
key = entry_key(host, port)
|
|
1043
|
+
if @entries[key].nil?
|
|
1044
|
+
action = prompt_user(host, port, fingerprint)
|
|
1045
|
+
if action == :accept
|
|
1046
|
+
@save_mutex.synchronize { @entries[key] = fingerprint; save }
|
|
1047
|
+
end
|
|
1048
|
+
{action: action.to_s}
|
|
1049
|
+
elsif @entries[key] == fingerprint
|
|
1050
|
+
{action: "accept"}
|
|
1051
|
+
else
|
|
1052
|
+
warn_host_key_changed(host, port, @entries[key], fingerprint)
|
|
1053
|
+
{action: "reject"}
|
|
1054
|
+
end
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
private
|
|
1058
|
+
|
|
1059
|
+
def prompt_user(host, port, fp)
|
|
1060
|
+
# V1.0 CLI:STDIN 询问;V2.0 TUI 弹窗,超时 30s 默认 reject
|
|
1061
|
+
puts "首次连接 #{host}:#{port},指纹 #{fp},是否信任?(y/N)"
|
|
1062
|
+
STDIN.gets&.chomp =~ /^y/i ? :accept : :reject
|
|
1063
|
+
end
|
|
1064
|
+
end
|
|
1065
|
+
end
|
|
1066
|
+
```
|
|
1067
|
+
|
|
1068
|
+
注册到 Router:
|
|
1069
|
+
```ruby
|
|
1070
|
+
router.subscribe("hostkey.resolve") do |p|
|
|
1071
|
+
result = host_key.resolve(p[:host], p[:port], p[:fingerprint])
|
|
1072
|
+
# 这是 Erlang→Ruby 的同步请求(带 id),需回响应
|
|
1073
|
+
router.reply_push(p[:id], result) # Router 需支持 reply_push 给带 id 的推送回响应
|
|
1074
|
+
end
|
|
1075
|
+
```
|
|
1076
|
+
|
|
1077
|
+
注意:`hostkey.resolve` 是双向的——Erlang 发来时带 `id`,Ruby 必须回一个同 `id` 的 result。这是 JSON-RPC 反向调用的一种变体。Router 需扩展支持。
|
|
1078
|
+
|
|
1079
|
+
### 7.9 Automation::MacroEngine
|
|
1080
|
+
|
|
1081
|
+
HLD 实现基本可用。修正:
|
|
1082
|
+
- `wait_for` 不轮询 `buffer.last_line`,改为订阅 `channel.data`,在事件到达时匹配,避免空转。
|
|
1083
|
+
- `on_fail: :ask` 通过外部传入的 `on_ask` 回调处理,不依赖 block_given。
|
|
1084
|
+
|
|
1085
|
+
### 7.10 Config::Schema / Store
|
|
1086
|
+
|
|
1087
|
+
```ruby
|
|
1088
|
+
module Config
|
|
1089
|
+
module Schema
|
|
1090
|
+
SCHEMA_VERSION = 1
|
|
1091
|
+
|
|
1092
|
+
SESSION = {
|
|
1093
|
+
id: String, name: String, group: String, host: String, port: Integer,
|
|
1094
|
+
user: String, tags: Array, auth: Hash, terminal: Hash,
|
|
1095
|
+
keepalive: Hash, proxy: Hash, jumps: Array, port_forwards: Array,
|
|
1096
|
+
macro: Hash, log: Hash
|
|
1097
|
+
}.freeze
|
|
1098
|
+
|
|
1099
|
+
def self.validate(sessions_doc)
|
|
1100
|
+
raise SchemaError, "version mismatch" unless sessions_doc[:version] == SCHEMA_VERSION
|
|
1101
|
+
sessions_doc[:sessions].each { |s| validate_session(s) }
|
|
1102
|
+
sessions_doc[:groups]&.each { |g| validate_group(g) }
|
|
1103
|
+
:ok
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1106
|
+
# 迁移:旧版本 → 当前版本
|
|
1107
|
+
def self.migrate(doc)
|
|
1108
|
+
# V1.0 仅 v1,预留
|
|
1109
|
+
doc
|
|
1110
|
+
end
|
|
1111
|
+
end
|
|
1112
|
+
|
|
1113
|
+
class Store
|
|
1114
|
+
def initialize(path)
|
|
1115
|
+
@path = path
|
|
1116
|
+
@doc = load
|
|
1117
|
+
end
|
|
1118
|
+
|
|
1119
|
+
def sessions; @doc[:sessions]; end
|
|
1120
|
+
def groups; @doc[:groups]; end
|
|
1121
|
+
def find_session(id); sessions.find { |s| s[:id] == id }; end
|
|
1122
|
+
|
|
1123
|
+
def save
|
|
1124
|
+
tmp = "#{@path}.tmp"
|
|
1125
|
+
File.write(tmp, YAML.dump(@doc))
|
|
1126
|
+
File.rename(tmp, @path)
|
|
1127
|
+
end
|
|
1128
|
+
end
|
|
1129
|
+
end
|
|
1130
|
+
```
|
|
1131
|
+
|
|
1132
|
+
**原子写**:先写 `.tmp` 再 `rename`,防止崩溃导致配置损坏。
|
|
1133
|
+
|
|
1134
|
+
### 7.11 SSH::Client(修正生命周期)
|
|
1135
|
+
|
|
1136
|
+
```ruby
|
|
1137
|
+
class Client
|
|
1138
|
+
attr_reader :ipc, :sessions, :vault, :settings, :coalesce
|
|
1139
|
+
|
|
1140
|
+
def initialize
|
|
1141
|
+
@settings = Config::Settings.new
|
|
1142
|
+
@vault = Security::Vault.new(@settings.master_password)
|
|
1143
|
+
@ipc = IPC::Router.new(IPC::Transport.new(nil)) # endpoint 占位,start_engine 后再连
|
|
1144
|
+
@sessions = Session::Manager.new(self)
|
|
1145
|
+
@coalesce = IPC::Coalesce.new(@ipc)
|
|
1146
|
+
@engine_pid = nil
|
|
1147
|
+
@engine_mutex = Mutex.new
|
|
1148
|
+
@started = false
|
|
1149
|
+
end
|
|
1150
|
+
|
|
1151
|
+
def start_engine
|
|
1152
|
+
@engine_mutex.synchronize do
|
|
1153
|
+
raise AlreadyStarted if @started
|
|
1154
|
+
@engine_pid = spawn_engine
|
|
1155
|
+
endpoint = wait_for_endpoint
|
|
1156
|
+
@ipc.connect(endpoint)
|
|
1157
|
+
@ipc.call("hello", {auth_token: read_token, ver: Protocol::VER, client_id: "ruby-#{Process.pid}"})
|
|
1158
|
+
@started = true
|
|
1159
|
+
end
|
|
1160
|
+
self
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
def connect(spec)
|
|
1164
|
+
ensure_started
|
|
1165
|
+
spec = @vault.resolve_credentials(spec) # 返回新 hash,不改入参
|
|
1166
|
+
conn_id = @ipc.call("conn.connect", spec, timeout_ms: 60_000)[:conn_id]
|
|
1167
|
+
@sessions.create(conn_id, spec)
|
|
1168
|
+
rescue RPCError => e
|
|
1169
|
+
raise ConnectionError, e.message
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
def stop
|
|
1173
|
+
@engine_mutex.synchronize do
|
|
1174
|
+
@ipc.call("bye", {}) rescue nil
|
|
1175
|
+
@ipc.close
|
|
1176
|
+
Process.kill("TERM", @engine_pid) if @engine_pid
|
|
1177
|
+
@engine_pid = nil
|
|
1178
|
+
@started = false
|
|
1179
|
+
end
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
private
|
|
1183
|
+
|
|
1184
|
+
def ensure_started
|
|
1185
|
+
start_engine unless @started
|
|
1186
|
+
end
|
|
1187
|
+
end
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
---
|
|
1191
|
+
|
|
1192
|
+
## 8 错误处理与级联策略
|
|
1193
|
+
|
|
1194
|
+
### 8.1 错误分级
|
|
1195
|
+
|
|
1196
|
+
| 级别 | 示例 | 策略 |
|
|
1197
|
+
|------|------|------|
|
|
1198
|
+
| 一次性操作失败 | `channel.send` 失败、`sftp.stat` 文件不存在 | 返回错误给调用方,不改变系统状态 |
|
|
1199
|
+
| 通道级故障 | channel EOF、exec 退出 | 关闭该通道,清理订阅,通知业务层;连接保持 |
|
|
1200
|
+
| 连接级故障 | 网络断开、SSH 协议错误 | worker 转入 reconnecting/failed;推 `conn.closed`;所有 channel 自动 eof |
|
|
1201
|
+
| 引擎级故障 | ipc_gateway 崩溃 | supervisor 重启 gateway;客户端重连 IPC;已建立的 ssh 连接不受影响(数据暂存) |
|
|
1202
|
+
|
|
1203
|
+
### 8.2 级联隔离
|
|
1204
|
+
|
|
1205
|
+
```
|
|
1206
|
+
ssh_conn_sup (simple_one_for_one, temporary)
|
|
1207
|
+
│ 单个 conn_worker 崩溃
|
|
1208
|
+
▼
|
|
1209
|
+
不触发 supervisor 重启策略(temporary + simple_one_for_one 崩溃即删,不重试)
|
|
1210
|
+
不波及 ssh_infra_sup 下的 gateway/keepalive/known_hosts
|
|
1211
|
+
```
|
|
1212
|
+
|
|
1213
|
+
- conn_worker 崩溃 → 其下所有 channel_stm 因 link 随之退出 → 状态在 Registry 中清除 → 推 `conn.closed`。
|
|
1214
|
+
- IPC gateway 崩溃重启 → TCP/Unix listener 重建 → 现有 ssh 连接的 ssh_ref 仍有效,但 push 通道中断 → Ruby 侧 `engine.ping` 失败 → Ruby 触发 `engine.restart` 或退出。
|
|
1215
|
+
- keepalive 崩溃 → rediscovery 阶段漏检;重启后重新扫描,可容忍。
|
|
1216
|
+
|
|
1217
|
+
### 8.3 重连规则(Erlang 不自主重连)
|
|
1218
|
+
|
|
1219
|
+
1. `conn.closed` 推送到达 Ruby。
|
|
1220
|
+
2. Ruby `Session::Manager` 检查该会话的 `auto_reconnect` 配置。
|
|
1221
|
+
3. 若开启,按指数退避调度 `conn.reconnect` RPC(不是重新 `conn.connect`,以复用原 conn_id 与配置)。
|
|
1222
|
+
4. `conn.reconnect` 成功 → 推 `conn.ready`;失败 → 继续退避直到最大次数 → `Session.status = :error`。
|
|
1223
|
+
|
|
1224
|
+
**防冲击**:全局重连令牌桶,最多每秒发起 5 次 `conn.reconnect`,避免 50 个连接同时断网后瞬时打满服务器。
|
|
1225
|
+
|
|
1226
|
+
### 8.4 Ruby 侧异常隔离
|
|
1227
|
+
|
|
1228
|
+
- reader thread 任何异常 → log + 通知 main → `Client#on_ipc_lost` 回调。
|
|
1229
|
+
- event dispatcher 异常 → 单条推送丢弃 + log,线程存活。
|
|
1230
|
+
- batch 解码失败 → 丢弃该 batch + log,不影响其他通道。
|
|
1231
|
+
- `hostkey.resolve` 处理超时 30s → 自动 reject。
|
|
1232
|
+
|
|
1233
|
+
---
|
|
1234
|
+
|
|
1235
|
+
## 9 配置体系与数据一致性
|
|
1236
|
+
|
|
1237
|
+
### 9.1 文件布局(沿用 HLD 7.3)
|
|
1238
|
+
|
|
1239
|
+
```
|
|
1240
|
+
~/.network-infra-utility/
|
|
1241
|
+
├── settings.yml
|
|
1242
|
+
├── sessions.yml
|
|
1243
|
+
├── vault.yml (权限 600/ACL)
|
|
1244
|
+
├── known_hosts.yml
|
|
1245
|
+
├── snippets/ themes/ macros/ logs/
|
|
1246
|
+
```
|
|
1247
|
+
|
|
1248
|
+
### 9.2 sessions.yml schema(形式化)
|
|
1249
|
+
|
|
1250
|
+
```yaml
|
|
1251
|
+
version: 1
|
|
1252
|
+
groups:
|
|
1253
|
+
- id: <grp_8hex>
|
|
1254
|
+
name: <string>
|
|
1255
|
+
parent: <grp_id | null>
|
|
1256
|
+
collapsed: <bool> # UI 折叠态
|
|
1257
|
+
sessions:
|
|
1258
|
+
- id: <sess_uuid>
|
|
1259
|
+
name: <string>
|
|
1260
|
+
group: <grp_id | null>
|
|
1261
|
+
host: <string>
|
|
1262
|
+
port: <int 1-65535> # default 22
|
|
1263
|
+
user: <string>
|
|
1264
|
+
tags: [<string>...]
|
|
1265
|
+
auth:
|
|
1266
|
+
type: password | publickey | keyboard_interactive
|
|
1267
|
+
key_path: <string>? # publickey
|
|
1268
|
+
password_ref: <~vault:key>? # password
|
|
1269
|
+
passphrase_ref: <~vault:key>?
|
|
1270
|
+
chain: [password, publickey]? # 回退顺序
|
|
1271
|
+
terminal:
|
|
1272
|
+
type: xterm-256color | vt100 | vt220
|
|
1273
|
+
theme: <string>
|
|
1274
|
+
font: <string>
|
|
1275
|
+
font_size: <int 8-32>
|
|
1276
|
+
scrollback: <int 1000-100000>
|
|
1277
|
+
encoding: utf-8 | gbk
|
|
1278
|
+
keepalive:
|
|
1279
|
+
interval: <int 5-300> # 秒
|
|
1280
|
+
count_max: <int 1-10>
|
|
1281
|
+
proxy:
|
|
1282
|
+
type: socks5 | http
|
|
1283
|
+
host: <string>
|
|
1284
|
+
port: <int>
|
|
1285
|
+
auth: {user, password_ref}?
|
|
1286
|
+
jumps:
|
|
1287
|
+
- host, port, user, auth # 同 auth 结构
|
|
1288
|
+
port_forwards:
|
|
1289
|
+
- {type: local|remote|dynamic, local_port, remote_host?, remote_port?, enabled}
|
|
1290
|
+
macro:
|
|
1291
|
+
enabled: <bool>
|
|
1292
|
+
steps: [{action, wait_pattern, delay, on_fail}]
|
|
1293
|
+
log:
|
|
1294
|
+
enabled: <bool>
|
|
1295
|
+
path: <string>
|
|
1296
|
+
max_size: <int MB>
|
|
1297
|
+
rotate: <int>
|
|
1298
|
+
auto_reconnect:
|
|
1299
|
+
enabled: <bool>
|
|
1300
|
+
max_attempts: <int 1-10>
|
|
1301
|
+
base_interval: <int 1-60>
|
|
1302
|
+
```
|
|
1303
|
+
|
|
1304
|
+
### 9.3 一致性规则
|
|
1305
|
+
|
|
1306
|
+
- 写入采用 **read-modify-write + 原子 rename**(9.10 已示)。
|
|
1307
|
+
- 运行期 hot-reload:V1.0 不支持热加载;修改配置需重连会话。V2.0 引入 `config.reload` RPC。
|
|
1308
|
+
- `version` 字段强制;不匹配时 `Schema.migrate` 尝试迁移,失败则拒绝加载并报错。
|
|
1309
|
+
|
|
1310
|
+
### 9.4 凭据引用解析时机
|
|
1311
|
+
|
|
1312
|
+
```
|
|
1313
|
+
sessions.yml 中 password_ref: "~vault:sess_001_pass"
|
|
1314
|
+
│
|
|
1315
|
+
▼ Session::Manager#connect
|
|
1316
|
+
Vault.resolve_credentials(spec) → spec[:auth][:password] = "明文"
|
|
1317
|
+
│
|
|
1318
|
+
▼ IPC conn.connect
|
|
1319
|
+
包含明文密码的 JSON-RPC(本地 IPC)
|
|
1320
|
+
│
|
|
1321
|
+
▼ Erlang conn_worker
|
|
1322
|
+
ssh:connect options 中 {password, Pwd}
|
|
1323
|
+
│
|
|
1324
|
+
▼ 连接建立后
|
|
1325
|
+
Erlang 进程字典中清除 Pwd(显式 erase),仅保留 ssh_ref
|
|
1326
|
+
Ruby 侧 spec 副本在 connect 返回后由 GC 回收
|
|
1327
|
+
```
|
|
1328
|
+
|
|
1329
|
+
---
|
|
1330
|
+
|
|
1331
|
+
## 10 可观测性设计
|
|
1332
|
+
|
|
1333
|
+
### 10.1 日志架构
|
|
1334
|
+
|
|
1335
|
+
| 组件 | 目标 | 格式 | rotation |
|
|
1336
|
+
|------|------|------|----------|
|
|
1337
|
+
| Erlang 引擎 | `~/.network-infra-utility/logs/engine-<date>.log` | 每行一个 JSON 对象(structured) | 按日 + 单文件 100MB |
|
|
1338
|
+
| Ruby 客户端 | `~/.network-infra-utility/logs/client-<date>.log` | 文本,`[ISO8601] [LEVEL] [module] msg` | 同上 |
|
|
1339
|
+
| 会话日志 | `logs/<sess_id>/<ts>.log` | 纯文本带时间戳;可选 HTML | 单文件 100MB / 保留 10 |
|
|
1340
|
+
|
|
1341
|
+
### 10.2 结构化日志字段(Erlang)
|
|
1342
|
+
|
|
1343
|
+
```erlang
|
|
1344
|
+
%% logger 配置(sys.config)
|
|
1345
|
+
{logger, [
|
|
1346
|
+
{handler, default, logger_std_h, #{
|
|
1347
|
+
config => #{file => "logs/engine.log", max_no_files => 10, max_no_bytes => 104857600},
|
|
1348
|
+
formatter => {logger_json_formatter, #{}}
|
|
1349
|
+
}}
|
|
1350
|
+
]}.
|
|
1351
|
+
```
|
|
1352
|
+
|
|
1353
|
+
每条日志字段:
|
|
1354
|
+
|
|
1355
|
+
| 字段 | 说明 |
|
|
1356
|
+
|------|------|
|
|
1357
|
+
| ts | ISO8601 |
|
|
1358
|
+
| level | debug/info/warning/error |
|
|
1359
|
+
| module | 模块名 |
|
|
1360
|
+
| conn_id | 关联连接(若有) |
|
|
1361
|
+
| channel_id | 关联通道(若有) |
|
|
1362
|
+
| event | 事件名 |
|
|
1363
|
+
| msg | 人类可读描述 |
|
|
1364
|
+
| fields | 任意结构化附加 |
|
|
1365
|
+
|
|
1366
|
+
### 10.3 关键事件清单
|
|
1367
|
+
|
|
1368
|
+
| event | level | 触发 |
|
|
1369
|
+
|-------|-------|------|
|
|
1370
|
+
| ipc.client_connected | info | Ruby 客户端 hello 成功 |
|
|
1371
|
+
| ipc.client_disconnected | info | 断开/超时 |
|
|
1372
|
+
| conn.connect_start | debug | 开始 ssh:connect |
|
|
1373
|
+
| conn.connect_ok | info | 连接成功 |
|
|
1374
|
+
| conn.connect_failed | warning | 失败含 reason |
|
|
1375
|
+
| conn.reconnect_try | info | 重连尝试含序号 |
|
|
1376
|
+
| conn.closed | info | 连接关闭 |
|
|
1377
|
+
| channel.open_ok | debug | 通道就绪 |
|
|
1378
|
+
| channel.eof | info | 通道 EOF |
|
|
1379
|
+
| hostkey.prompt | info | 请求 Ruby 裁决 |
|
|
1380
|
+
| hostkey.changed | warning | 主机密钥变更 |
|
|
1381
|
+
| auth.method | debug | 认证方式尝试 |
|
|
1382
|
+
| auth.failed | warning | 认证失败 |
|
|
1383
|
+
| flow.pause | info | 背压暂停 |
|
|
1384
|
+
| flow.resume | info | 背压恢复 |
|
|
1385
|
+
| sftp.transfer_ok | info | 传输完成含字节数/耗时 |
|
|
1386
|
+
| engine.shutdown | info | 引擎关闭 |
|
|
1387
|
+
|
|
1388
|
+
### 10.4 engine.stats
|
|
1389
|
+
|
|
1390
|
+
```json
|
|
1391
|
+
{"method":"engine.stats","params":{}}
|
|
1392
|
+
→
|
|
1393
|
+
{"result":{
|
|
1394
|
+
"uptime_ms": 1234567,
|
|
1395
|
+
"connections": {"total": 5, "ready": 4, "reconnecting": 1, "failed": 0},
|
|
1396
|
+
"channels": 12,
|
|
1397
|
+
"sftp_sessions": 2,
|
|
1398
|
+
"memory_kb": {"total": 51200, "processes": 10240, "binary": 30720},
|
|
1399
|
+
"ipc": {"clients": 1, "push_q_depth": 0, "push_dropped": 0},
|
|
1400
|
+
"coalesce": {"batches_sent": 1024, "items_total": 8192}
|
|
1401
|
+
}}
|
|
1402
|
+
```
|
|
1403
|
+
|
|
1404
|
+
---
|
|
1405
|
+
|
|
1406
|
+
## 11 模块完成判定标准(Definition of Done)
|
|
1407
|
+
|
|
1408
|
+
每个模块满足下列条件即为 V1.0 done。
|
|
1409
|
+
|
|
1410
|
+
### 11.1 Erlang 侧
|
|
1411
|
+
|
|
1412
|
+
| 模块 | DoD |
|
|
1413
|
+
|------|-----|
|
|
1414
|
+
| ssh_ipc_gateway | hello/bye 握手通过;所有路由方法可达;token 校验生效;单 client 1000 RPC/s 稳定 1min 无丢消息 |
|
|
1415
|
+
| ssh_ipc_coalesce | batch 合并生效;watermark 触发 flow.pause/resume;`yes` 指令 60s 无 Ruby 侧丢包 |
|
|
1416
|
+
| ssh_conn_worker | 密码/公钥/keyboard_interactive 三种方式分别连 OpenSSH 成功;跳板单级成功;状态机各分支覆盖 |
|
|
1417
|
+
| ssh_channel_stm | shell 通道交互 ≥ 10000 行输出无错乱;window_change 生效;eof 正确通知 |
|
|
1418
|
+
| ssh_auth_engine | key_cb 成功加载 OpenSSH/PKCS8/SEC1 三种私钥格式;认证回退链生效 |
|
|
1419
|
+
| ssh_jump_chain | 单级跳板连通目标;多级标记为 V2.0 但接口齐备 |
|
|
1420
|
+
| ssh_port_fwd | Local + Remote 转发各通过 curl 验证;Dynamic 接口齐备 |
|
|
1421
|
+
| ssh_sftp_session | list/download/upload/mkdir/remove/stat 全通过;1GB 文件上传校验一致 |
|
|
1422
|
+
| ssh_keepalive_mgr | 心跳间隔可配;连续 3 次失败触发 conn.closed |
|
|
1423
|
+
| ssh_known_hosts_proxy | 首次连接触发 Ruby 裁决;变更触发 reject;超时 reject |
|
|
1424
|
+
|
|
1425
|
+
### 11.2 Ruby 侧
|
|
1426
|
+
|
|
1427
|
+
| 模块 | DoD |
|
|
1428
|
+
|------|-----|
|
|
1429
|
+
| SSH::Client | start/stop 生命周期稳定;引擎异常能感知并报错 |
|
|
1430
|
+
| IPC::Transport | 分帧正确;EOF 优雅关闭 |
|
|
1431
|
+
| IPC::Router | 1000 次并发 call 全部正确配对;订阅注册/注销无泄漏 |
|
|
1432
|
+
| IPC::Coalesce | batch 消费正确;注销后不再收到数据 |
|
|
1433
|
+
| Session::Manager | connect/disconnect/search/broadcast 语义正确;50 并发会话无死锁 |
|
|
1434
|
+
| Session::Session | open_terminal/close_terminal/disconnect 正确清理订阅 |
|
|
1435
|
+
| Terminal::Emulator | ANSI 覆盖率 ≥ 95%(用 vttest 子集验证);URL/IP 识别可点 |
|
|
1436
|
+
| Terminal::Screen | 80x24 标准 vt100 行为;alt buffer;滚动区 |
|
|
1437
|
+
| Terminal::Buffer | 10000 行回滚;search ≤ 200ms |
|
|
1438
|
+
| Terminal::Theme | ≥ 10 套内置;导入 iTerm2 配色通过 |
|
|
1439
|
+
| Terminal::Logger | 自动轮转;HTML 格式带颜色还原 |
|
|
1440
|
+
| Security::Vault | AES-256-GCM 加解密一致;文件权限 600/ACL;空主密码拒绝 |
|
|
1441
|
+
| Security::HostKey | 首次/匹配/变更三种裁决路径通过;30s 超时 reject |
|
|
1442
|
+
| Automation::MacroEngine | 50 步宏执行;wait_pattern 匹配;on_fail 三分支 |
|
|
1443
|
+
| Config::Schema | v1 schema 校验;非法配置明确报错 |
|
|
1444
|
+
| Config::Store | 原子写;崩溃后文件不损坏 |
|
|
1445
|
+
|
|
1446
|
+
---
|
|
1447
|
+
|
|
1448
|
+
## 12 测试矩阵
|
|
1449
|
+
|
|
1450
|
+
### 12.1 Erlang Common Test 套件
|
|
1451
|
+
|
|
1452
|
+
| 套件 | 覆盖 |
|
|
1453
|
+
|------|------|
|
|
1454
|
+
| ssh_ipc_gateway_SUITE | hello/bye/ping、token、路由、coalesce、背压 |
|
|
1455
|
+
| ssh_conn_worker_SUITE | 三种认证、状态机、超时 |
|
|
1456
|
+
| ssh_channel_stm_SUITE | shell/exec、window_change、eof、大批量数据 |
|
|
1457
|
+
| ssh_auth_engine_SUITE | key_cb 各格式、回退链 |
|
|
1458
|
+
| ssh_jump_chain_SUITE | 单级、多级(可 skip V1.0) |
|
|
1459
|
+
| ssh_port_fwd_SUITE | Local/Remote、规则增删 |
|
|
1460
|
+
| ssh_sftp_session_SUITE | 全操作、大文件、并发会话 |
|
|
1461
|
+
| ssh_keepalive_mgr_SUITE | 心跳、失败计数、conn.closed |
|
|
1462
|
+
| ssh_known_hosts_proxy_SUITE | 三种裁决 + 超时 |
|
|
1463
|
+
|
|
1464
|
+
### 12.2 Ruby RSpec
|
|
1465
|
+
|
|
1466
|
+
| spec | 覆盖 |
|
|
1467
|
+
|------|------|
|
|
1468
|
+
| ipc/transport_spec | 分帧、EOF |
|
|
1469
|
+
| ipc/router_spec | 并发 call、订阅、超时 |
|
|
1470
|
+
| ipc/coalesce_spec | batch、注销 |
|
|
1471
|
+
| session/manager_spec | CRUD、search、broadcast |
|
|
1472
|
+
| session/session_spec | 生命周期 |
|
|
1473
|
+
| terminal/emulator_spec | ANSI 解析(用固定向量) |
|
|
1474
|
+
| terminal/buffer_spec | 回滚、search 性能 |
|
|
1475
|
+
| security/vault_spec | 加解密、权限 |
|
|
1476
|
+
| security/host_key_spec | 裁决路径 |
|
|
1477
|
+
| automation/macro_engine_spec | 步骤、wait、on_fail |
|
|
1478
|
+
| config/schema_spec | 校验、迁移 |
|
|
1479
|
+
|
|
1480
|
+
### 12.3 端到端集成
|
|
1481
|
+
|
|
1482
|
+
| 场景 | 步骤 | 通过标准 |
|
|
1483
|
+
|------|------|---------|
|
|
1484
|
+
| 基本连接 | 启动引擎 → connect OpenSSH → 交互 `ls` → 断开 | 输出正确,无残留进程 |
|
|
1485
|
+
| 公钥认证 | 用 ed25519 key 连接 | 成功,日志显示 publickey |
|
|
1486
|
+
| 单级跳板 | 经 J1 连目标 | 目标 shell 可用 |
|
|
1487
|
+
| SFTP 上传 | 1GB 文件上传 | 校验一致,耗时记录 |
|
|
1488
|
+
| 端口转发 | Local 8080→远端 80 | curl 本地 8080 返回远端页面 |
|
|
1489
|
+
| 保活断连 | 拔网 5s 恢复 | conn.closed → reconnect → ready |
|
|
1490
|
+
| 50 并发 | 50 会话同时 `ls` | 全部成功,内存 ≤ 500MB |
|
|
1491
|
+
| 主机密钥变更 | 换主机 key 再连 | 高亮告警,连接拒绝 |
|
|
1492
|
+
| 登录宏 | Cisco 设备登录宏 | 按步执行到 `#` prompt |
|
|
1493
|
+
|
|
1494
|
+
---
|
|
1495
|
+
|
|
1496
|
+
## 附录 A:V1.0 风险登记
|
|
1497
|
+
|
|
1498
|
+
| 风险 | 等级 | 缓解 |
|
|
1499
|
+
|------|------|------|
|
|
1500
|
+
| `{sock_fun}` 跳板不稳 | 高 | V1.0 仅单级;多跳用本地端口转发链降级 |
|
|
1501
|
+
| ANSI 覆盖率 95% 工作量大 | 高 | 借用 tty/vte 转义表,先覆盖 xterm 常用子集 |
|
|
1502
|
+
| otp ssh key_cb 与 known_hosts 交互复杂 | 中 | known_hosts 走 Ruby 落盘,Erlang 仅裁决 |
|
|
1503
|
+
| Ruby 单线程 event loop 在高频数据下饱和 | 中 | coalesce + batch;V2.0 评估 Celluloid |
|
|
1504
|
+
| 跨平台 Erlang release 体积大 | 中 | strip + 压缩;Windows ERTS 约 40MB |
|
|
1505
|
+
| vault.yml 主密码丢失不可恢复 | 中 | 文档提示 + V2.0 考虑恢复码 |
|
|
1506
|
+
|
|
1507
|
+
## 附录 B:与 HLD 章节对照
|
|
1508
|
+
|
|
1509
|
+
| 本文档章节 | 对应 HLD 章节 | 关系 |
|
|
1510
|
+
|------------|---------------|------|
|
|
1511
|
+
| 2 勘误 | HLD 4.2 | 修正 |
|
|
1512
|
+
| 3 模块全景 | HLD 4.1 / 5.1 | 细化 |
|
|
1513
|
+
| 4 不变量 | HLD 无 | 新增 |
|
|
1514
|
+
| 5 IPC v2 | HLD 6 | 细化+补缺 |
|
|
1515
|
+
| 6 Erlang 详细 | HLD 4.2 | 细化+修正 |
|
|
1516
|
+
| 7 Ruby 详细 | HLD 5.2 | 细化 |
|
|
1517
|
+
| 8 错误级联 | HLD 无 | 新增 |
|
|
1518
|
+
| 9 配置 | HLD 7 | 形式化 |
|
|
1519
|
+
| 10 可观测性 | HLD 无 | 新增 |
|
|
1520
|
+
| 11 DoD | HLD 无 | 新增 |
|
|
1521
|
+
| 12 测试矩阵 | HLD 10.4 | 细化 |
|