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,2493 @@
|
|
|
1
|
+
|
|
2
|
+
# SSH 连接客户端软件设计文档
|
|
3
|
+
|
|
4
|
+
> **文档版本**:v1.0
|
|
5
|
+
> **编写日期**:2026-08-02
|
|
6
|
+
> **文档状态**:初稿
|
|
7
|
+
> **对应需求**:SSH连接客户端功能需求文档 v1.0
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 目录
|
|
12
|
+
|
|
13
|
+
- [1 设计概述](#1-设计概述)
|
|
14
|
+
- [2 系统架构](#2-系统架构)
|
|
15
|
+
- [3 技术选型](#3-技术选型)
|
|
16
|
+
- [4 Erlang SSH 核心引擎设计](#4-erlang-ssh-核心引擎设计)
|
|
17
|
+
- [5 Ruby 调度界面设计](#5-ruby-调度界面设计)
|
|
18
|
+
- [6 通信协议设计](#6-通信协议设计)
|
|
19
|
+
- [7 数据模型设计](#7-数据模型设计)
|
|
20
|
+
- [8 安全设计](#8-安全设计)
|
|
21
|
+
- [9 需求映射矩阵](#9-需求映射矩阵)
|
|
22
|
+
- [10 部署与打包](#10-部署与打包)
|
|
23
|
+
- [11 三期迭代设计](#11-三期迭代设计)
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 1 设计概述
|
|
28
|
+
|
|
29
|
+
### 1.1 编写目的
|
|
30
|
+
|
|
31
|
+
本文档基于《SSH 连接客户端功能需求文档》的全部 51 条功能需求与 20 条非功能需求,定义系统的架构分层、模块划分、接口协议、数据模型和安全机制,为开发团队提供可落地的工程蓝图。
|
|
32
|
+
|
|
33
|
+
### 1.2 设计目标
|
|
34
|
+
|
|
35
|
+
| 目标 | 量化指标 | 对应需求 |
|
|
36
|
+
|------|----------|----------|
|
|
37
|
+
| 高并发连接 | 单实例 ≥ 50 个活跃 SSH 会话 | NFR-PERF-006 |
|
|
38
|
+
| 低延迟交互 | 按键到屏幕回显 ≤ 50ms | NFR-PERF-003 |
|
|
39
|
+
| 高可靠运行 | 连续运行 72h 无崩溃 | NFR-REL-001 |
|
|
40
|
+
| 跨平台一致 | Windows/macOS/Linux 功能一致度 ≥ 95% | FR-COLLAB-001 |
|
|
41
|
+
| 低资源占用 | 10 个活跃会话内存 ≤ 500MB | NFR-PERF-005 |
|
|
42
|
+
|
|
43
|
+
### 1.3 架构决策摘要
|
|
44
|
+
|
|
45
|
+
| 决策点 | 选择 | 理由 |
|
|
46
|
+
|--------|------|------|
|
|
47
|
+
| SSH 协议栈语言 | Erlang/OTP | OTP 的 ssh 应用提供成熟 SSH2 协议栈,轻量进程模型天然适配高并发连接管理 |
|
|
48
|
+
| 调度与 UI 层语言 | Ruby | 与 network-infra-utility 项目主语言统一,gem 生态丰富,开发效率高 |
|
|
49
|
+
| 进程间通信 | JSON-RPC 2.0 over Unix Socket / TCP | 语言无关、调试友好、Unix Socket 零拷贝低延迟 |
|
|
50
|
+
| UI 渲染方案 | 分阶段:CLI → TUI(ruby-curses) → GUI(可选) | 降低首版复杂度,先交付可交互 CLI,逐步增强 |
|
|
51
|
+
| 配置存储格式 | YAML + 加密字段(AES-256-GCM) | YAML 可读性好,敏感字段单独加密 |
|
|
52
|
+
| 终端仿真 | Ruby 侧 VT100/xterm 转义解析 + Erlang 侧透传 | 转义解析近 UI 层,协议层保持透传 |
|
|
53
|
+
|
|
54
|
+
### 1.4 设计约束
|
|
55
|
+
|
|
56
|
+
- Erlang 侧不引入第三方 SSH 库,直接基于 OTP `ssh` 应用(Erlang/OTP 26+ 内置)。
|
|
57
|
+
- Ruby 侧不直接处理任何 TCP/SSH 协议细节,所有网络连接由 Erlang 层代理。
|
|
58
|
+
- 跨平台编译:Windows 使用 Rebar3 + MSVC,macOS/Linux 使用 Rebar3 + GCC/Clang。
|
|
59
|
+
- 最终产物为单一守护进程(Erlang)+ Ruby gem 前端,二者通过本地 IPC 耦合。
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 2 系统架构
|
|
64
|
+
|
|
65
|
+
### 2.1 总体架构图
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
69
|
+
│ Ruby 调度界面层 (Frontend) │
|
|
70
|
+
│ │
|
|
71
|
+
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐│
|
|
72
|
+
│ │ 会话管理 │ │ 终端仿真 │ │ 文件管理 │ │ 自动化 │ │ 安全 ││
|
|
73
|
+
│ │ SessMgr │ │ Terminal │ │ FileMgr │ │ Engine │ │ Vault ││
|
|
74
|
+
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───┬────┘│
|
|
75
|
+
│ │ │ │ │ │ │
|
|
76
|
+
│ ┌────┴────────────┴────────────┴─────────────┴───────────┴────┐ │
|
|
77
|
+
│ │ IPC Client (JSON-RPC 2.0 Adapter) │ │
|
|
78
|
+
│ └────────────────────────┬─────────────────────────────────────┘ │
|
|
79
|
+
└───────────────────────────┼───────────────────────────────────────┘
|
|
80
|
+
│ Unix Socket / 127.0.0.1:TCP
|
|
81
|
+
│ JSON-RPC 2.0
|
|
82
|
+
┌───────────────────────────┼───────────────────────────────────────┐
|
|
83
|
+
│ Erlang SSH 核心引擎层 (Backend) │
|
|
84
|
+
│ ┌────────────────────────┴─────────────────────────────────────┐ │
|
|
85
|
+
│ │ IPC Gateway (JSON-RPC Dispatcher) │ │
|
|
86
|
+
│ └────┬────────────┬─────────────┬────────────┬──────────────────┘ │
|
|
87
|
+
│ │ │ │ │ │
|
|
88
|
+
│ ┌────┴────┐ ┌─────┴─────┐ ┌─────┴────┐ ┌─────┴─────┐ ┌──────────┐│
|
|
89
|
+
│ │ 连接池 │ │ 认证引擎 │ │ 端口转发 │ │ SFTP 引擎 │ │ 跳板链 ││
|
|
90
|
+
│ │ConnPool │ │AuthEngine│ │PortFwd │ │SftpEngine│ │JumpChain ││
|
|
91
|
+
│ └────┬────┘ └──────────┘ └──────────┘ └───────────┘ └──────────┘│
|
|
92
|
+
│ │ │
|
|
93
|
+
│ ┌────┴──────────────────────────────────────────────────────────┐│
|
|
94
|
+
│ │ Erlang/OTP ssh 应用 (SSH2 协议栈) ││
|
|
95
|
+
│ │ 密钥交换 │ 对称加密 │ MAC │ 压缩 │ 通道复用 ││
|
|
96
|
+
│ └───────────────────────────────────────────────────────────────┘│
|
|
97
|
+
│ │ │
|
|
98
|
+
│ ┌───────────────────────────┴───────────────────────────────────┐│
|
|
99
|
+
│ │ TCP / 网络 I/O ││
|
|
100
|
+
│ └───────────────────────────────────────────────────────────────┘│
|
|
101
|
+
└──────────────────────────────────────────────────────────────────┘
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 2.2 分层职责
|
|
105
|
+
|
|
106
|
+
| 层 | 语言 | 核心职责 | 不负责 |
|
|
107
|
+
|-------------|--------|---------|--------|
|
|
108
|
+
| Ruby 调度层 | Ruby | 会话编排、配置管理、终端渲染、自动化脚本、凭据加解密、用户交互 | 不直接建立 TCP 连接、不处理 SSH 二进制协议 |
|
|
109
|
+
| IPC 通道 | 双端 | JSON-RPC 2.0 消息封装与路由 | 不包含业务逻辑 |
|
|
110
|
+
| Erlang 核心层 | Erlang | SSH 握手、认证、加密通道管理、端口转发、SFTP 子系统、连接池、跳板链 | 不做 UI 渲染、不做配置持久化 |
|
|
111
|
+
| OTP ssh 应用 | Erlang | SSH2 协议实现(RFC 4250-4256) | 由 OTP 维护 |
|
|
112
|
+
|
|
113
|
+
### 2.3 数据流举例:用户连接一台服务器
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
用户点击"连接"
|
|
117
|
+
│
|
|
118
|
+
▼
|
|
119
|
+
Ruby SessMgr.build_connect_spec(session)
|
|
120
|
+
│ 构建 JSON-RPC 请求: {"method":"conn.connect","params":{...}}
|
|
121
|
+
▼
|
|
122
|
+
IPC Client ──Unix Socket──▶ Erlang IPC Gateway
|
|
123
|
+
│
|
|
124
|
+
▼
|
|
125
|
+
Erlang ConnPool.spawn_connection(spec)
|
|
126
|
+
│ 调用 ssh:connect/3 建立 TCP + SSH 握手
|
|
127
|
+
│ 执行认证(密码/公钥/keyboard-interactive)
|
|
128
|
+
▼
|
|
129
|
+
Erlang 返回: {"result":{"channel_id":"ch_001","fingerprint":"SHA256:..."}}
|
|
130
|
+
│
|
|
131
|
+
▼
|
|
132
|
+
Ruby Terminal.create(channel_id)
|
|
133
|
+
│ 后续终端数据双向流:
|
|
134
|
+
│ Ruby → Erlang: {"method":"channel.send","params":{"id":"ch_001","data":"ls\n"}}
|
|
135
|
+
│ Erlang → Ruby: 推送 (push) {"method":"channel.data","params":{"id":"ch_001","data":"..."}}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### 2.4 进程模型
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
Erlang VM (beam)
|
|
142
|
+
├── ssh_core_sup (顶层 supervisor)
|
|
143
|
+
│ ├── ipc_gateway (gen_server, 1个, 监听 Unix Socket)
|
|
144
|
+
│ ├── conn_pool_sup (simple_one_for_one, N个连接)
|
|
145
|
+
│ │ ├── conn_worker_1 (gen_server, 1个SSH连接)
|
|
146
|
+
│ │ │ ├── channel_1 (gen_fsm, shell通道)
|
|
147
|
+
│ │ │ ├── channel_2 (gen_fsm, sftp通道)
|
|
148
|
+
│ │ │ └── portfwd_1 (gen_server, 端口转发)
|
|
149
|
+
│ │ ├── conn_worker_2
|
|
150
|
+
│ │ └── ...
|
|
151
|
+
│ ├── auth_engine (gen_server, 认证状态机)
|
|
152
|
+
│ ├── sftp_engine_sup (supervisor, SFTP子池)
|
|
153
|
+
│ └── keepalive_mgr (gen_server, 心跳巡检)
|
|
154
|
+
│
|
|
155
|
+
Ruby Process
|
|
156
|
+
├── main thread
|
|
157
|
+
│ ├── SessMgr (会话调度)
|
|
158
|
+
│ ├── TerminalRouter (终端数据分发)
|
|
159
|
+
│ └── IPC::Client (JSON-RPC 客户端, 1个连接)
|
|
160
|
+
├── event_loop thread (监听 Erlang push 消息)
|
|
161
|
+
└── vault_worker thread (凭据加解密)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 3 技术选型
|
|
167
|
+
|
|
168
|
+
### 3.1 Erlang 侧
|
|
169
|
+
|
|
170
|
+
| 组件 | 选择 | 版本要求 | 说明 |
|
|
171
|
+
|------|------|----------|------|
|
|
172
|
+
| 运行时 | Erlang/OTP | 26+ | 内置 ssh 应用,支持 curve25519-sha256 |
|
|
173
|
+
| 构建工具 | Rebar3 | 3.20+ | OTP 标准构建工具 |
|
|
174
|
+
| SSH 协议栈 | OTP ssh | 随 OTP | 无第三方依赖 |
|
|
175
|
+
| JSON 解析 | jsx | 3.1+ | 纯 Erlang JSON 库,编译进 release |
|
|
176
|
+
| 日志 | logger | OTP 内置 | 结构化日志 |
|
|
177
|
+
| 加密 | crypto | OTP 内置 | 底层调用 OpenSSL |
|
|
178
|
+
| 测试框架 | Common Test | OTP 内置 | OTP 标准测试框架 |
|
|
179
|
+
|
|
180
|
+
### 3.2 Ruby 侧
|
|
181
|
+
|
|
182
|
+
| 组件 | 选择 | 版本要求 | 说明 |
|
|
183
|
+
|------|------|----------|------|
|
|
184
|
+
| 运行时 | Ruby | 3.1+ | 项目已在用 |
|
|
185
|
+
| 终端渲染 | curses / reline | 标准库 | V1.0 CLI 渲染 |
|
|
186
|
+
| JSON | json | 标准库 | JSON-RPC 通信 |
|
|
187
|
+
| 加密 | openssl | 标准库 | AES-256-GCM 凭据加密 |
|
|
188
|
+
| 配置 | yaml | 标准库 | 会话与设置持久化 |
|
|
189
|
+
| 测试框架 | RSpec | 3.12+ | 与项目现有 spec 一致 |
|
|
190
|
+
| CLI 框架 | thor | 1.2+ | 命令行参数解析 |
|
|
191
|
+
| 终端 TUI | curses | 标准库 | V2.0 多面板 TUI |
|
|
192
|
+
|
|
193
|
+
### 3.3 选型对比记录
|
|
194
|
+
|
|
195
|
+
#### SSH 协议语言对比
|
|
196
|
+
|
|
197
|
+
| 方案 | 优势 | 劣势 | 结论 |
|
|
198
|
+
|------|------|------|------|
|
|
199
|
+
| **Erlang/OTP ssh** | 协议栈成熟,进程模型天然适配高并发,容错监督树 | 生态偏后端,UI 无原生支持 | **采用**——协议核心放 Erlang |
|
|
200
|
+
| Ruby net/ssh | 与前端同语言,调用简单 | 单线程连接管理受限,协议实现不如官方,高并发性能差 | 否决——无法满足 50 并发 |
|
|
201
|
+
| Rust russh | 性能最优,内存安全 | 生态不如 OTP 成熟,开发周期长 | 否决——ROI 不够 |
|
|
202
|
+
| Go x/crypto/ssh | 编译单二进制,并发好 | GC 暂停影响终端低延迟,ssh 库功能覆盖中等 | 备选——V2.0 评估 |
|
|
203
|
+
|
|
204
|
+
#### 前端语言对比
|
|
205
|
+
|
|
206
|
+
| 方案 | 优势 | 劣势 | 结论 |
|
|
207
|
+
|------|------|------|------|
|
|
208
|
+
| **Ruby** | 与 network-infra-utility 统一,gem 生态丰富,开发效率高 | GUI 生态弱,性能不如编译型语言 | **采用**——CLI/TUI 先行 |
|
|
209
|
+
| Electron/TS | 跨平台 GUI 生态最成熟 | 引入 JS 生态,内存占用大,偏离项目技术栈 | 否决 |
|
|
210
|
+
| Qt/C++ | GUI 性能最优 | 开发效率低,编译复杂 | 备选——V3.0 差异化评估 |
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 4 Erlang SSH 核心引擎设计
|
|
215
|
+
|
|
216
|
+
### 4.1 应用结构
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
ssh_core/
|
|
220
|
+
├── src/
|
|
221
|
+
│ ├── ssh_core.app.src % OTP application 资源文件
|
|
222
|
+
│ ├── ssh_core_sup.erl % 顶层 supervisor
|
|
223
|
+
│ ├── ssh_core_app.erl % application 回调
|
|
224
|
+
│ ├── ssh_ipc_gateway.erl % IPC 网关 (gen_server)
|
|
225
|
+
│ ├── ssh_ipc_proto.erl % JSON-RPC 协议编解码
|
|
226
|
+
│ ├── ssh_conn_pool_sup.erl % 连接池 supervisor
|
|
227
|
+
│ ├── ssh_conn_worker.erl % 单连接工作进程 (gen_server)
|
|
228
|
+
│ ├── ssh_channel_fsm.erl % SSH 通道状态机 (gen_fsm)
|
|
229
|
+
│ ├── ssh_auth_engine.erl % 认证引擎
|
|
230
|
+
│ ├── ssh_jump_chain.erl % 跳板链构建器
|
|
231
|
+
│ ├── ssh_port_fwd.erl % 端口转发管理器
|
|
232
|
+
│ ├── ssh_sftp_engine.erl % SFTP 引擎
|
|
233
|
+
│ ├── ssh_keepalive_mgr.erl % 保活巡检器
|
|
234
|
+
│ ├── ssh_known_hosts.erl % 已知主机管理
|
|
235
|
+
│ └── ssh_codec.erl % 共用编解码工具
|
|
236
|
+
├── rebar.config
|
|
237
|
+
├── test/
|
|
238
|
+
│ ├── ssh_ipc_gateway_SUITE.erl
|
|
239
|
+
│ ├── ssh_conn_worker_SUITE.erl
|
|
240
|
+
│ └── ...
|
|
241
|
+
└── Makefile
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### 4.2 模块详细设计
|
|
245
|
+
|
|
246
|
+
#### 4.2.1 ssh_ipc_gateway — IPC 网关
|
|
247
|
+
|
|
248
|
+
**职责**:监听本地 Unix Socket(Windows 用 127.0.0.1 TCP),接受 Ruby 端连接,将 JSON-RPC 请求分发到对应模块,异步事件推送给 Ruby。
|
|
249
|
+
|
|
250
|
+
```erlang
|
|
251
|
+
%% ssh_ipc_gateway.erl 核心接口
|
|
252
|
+
-module(ssh_ipc_gateway).
|
|
253
|
+
-behaviour(gen_server).
|
|
254
|
+
|
|
255
|
+
%% API
|
|
256
|
+
-export([start_link/1, push_event/2]).
|
|
257
|
+
%% gen_server callbacks
|
|
258
|
+
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
|
259
|
+
|
|
260
|
+
%% 状态
|
|
261
|
+
-record(state, {
|
|
262
|
+
listen_socket :: gen_tcp:socket() | undefined, % 监听 socket
|
|
263
|
+
clients = #{} :: #{pid() => #client{}}, % 已连接的 Ruby 客户端
|
|
264
|
+
rpc_routes :: #{binary() => {module(), atom()}} % 方法路由表
|
|
265
|
+
}).
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**监听策略**:
|
|
269
|
+
|
|
270
|
+
| 平台 | 传输方式 | 地址 |
|
|
271
|
+
|------|----------|------|
|
|
272
|
+
| macOS/Linux | Unix Socket | `/tmp/ssh_core_<user>.sock` |
|
|
273
|
+
| Windows | TCP | `127.0.0.1:随机高端口`(写入临时文件供 Ruby 读取) |
|
|
274
|
+
|
|
275
|
+
**认证机制**:Ruby 启动时从临时文件读取端口号和随机令牌,首次 JSON-RPC 请求带 `auth_token` 字段,网关校验后绑定会话。
|
|
276
|
+
|
|
277
|
+
**请求分发**:
|
|
278
|
+
|
|
279
|
+
```erlang
|
|
280
|
+
%% 分发逻辑
|
|
281
|
+
handle_info({tcp, Socket, Data}, State) ->
|
|
282
|
+
case ssh_ipc_proto:decode(Data) of
|
|
283
|
+
{ok, #{<<"id">> := Id, <<"method">> := Method, <<"params">> := Params}} ->
|
|
284
|
+
{Module, Fun} = maps:get(Method, State#state.rpc_routes),
|
|
285
|
+
spawn(fun() ->
|
|
286
|
+
Result = try
|
|
287
|
+
{ok, Module:Fun(Params)}
|
|
288
|
+
catch
|
|
289
|
+
Class:Reason -> {error, #{class => Class, reason => Reason}}
|
|
290
|
+
end,
|
|
291
|
+
Response = ssh_ipc_proto:encode_response(Id, Result),
|
|
292
|
+
gen_tcp:send(Socket, Response)
|
|
293
|
+
end),
|
|
294
|
+
{noreply, State};
|
|
295
|
+
{ok, #{<<"method">> := Method, <<"params">> := Params}} ->
|
|
296
|
+
%% push notification, 不需要回复
|
|
297
|
+
handle_push(Method, Params),
|
|
298
|
+
{noreply, State}
|
|
299
|
+
end.
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
#### 4.2.2 ssh_conn_worker — 连接工作进程
|
|
303
|
+
|
|
304
|
+
**职责**:管理一条完整的 SSH 连接,包括握手、认证、通道管理和连接生命周期。
|
|
305
|
+
|
|
306
|
+
**状态机**:
|
|
307
|
+
|
|
308
|
+
```
|
|
309
|
+
┌──────────┐
|
|
310
|
+
│ idle │
|
|
311
|
+
└────┬─────┘
|
|
312
|
+
│ connect/2
|
|
313
|
+
▼
|
|
314
|
+
┌──────────┐ 失败 ┌──────────┐
|
|
315
|
+
│connecting│───────────▶│ failed │
|
|
316
|
+
└────┬─────┘ └──────────┘
|
|
317
|
+
│ TCP connected
|
|
318
|
+
▼
|
|
319
|
+
┌──────────┐ 失败 ┌──────────┐
|
|
320
|
+
│handshaking│──────────▶│ failed │
|
|
321
|
+
└────┬─────┘ └──────────┘
|
|
322
|
+
│ SSH banner + KEX
|
|
323
|
+
▼
|
|
324
|
+
┌──────────┐ 失败 ┌──────────┐
|
|
325
|
+
│authenticating│───────▶│ failed │
|
|
326
|
+
└────┬─────┘ └──────────┘
|
|
327
|
+
│ auth success
|
|
328
|
+
▼
|
|
329
|
+
┌──────────┐
|
|
330
|
+
│ ready │◀─── 重连成功
|
|
331
|
+
└────┬─────┘
|
|
332
|
+
│ disconnect / 网络断开
|
|
333
|
+
▼
|
|
334
|
+
┌──────────┐ 重试 ┌──────────┐
|
|
335
|
+
│reconnect │───────────▶│ ready │
|
|
336
|
+
└────┬─────┘ 超时 └──────────┘
|
|
337
|
+
│ max retries
|
|
338
|
+
▼
|
|
339
|
+
┌──────────┐
|
|
340
|
+
│ closed │
|
|
341
|
+
└──────────┘
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
```erlang
|
|
345
|
+
%% ssh_conn_worker.erl
|
|
346
|
+
-module(ssh_conn_worker).
|
|
347
|
+
-behaviour(gen_server).
|
|
348
|
+
|
|
349
|
+
-record(conn, {
|
|
350
|
+
id :: binary(), % 连接唯一 ID
|
|
351
|
+
spec :: map(), % 连接参数
|
|
352
|
+
ssh_ref :: reference() | undefined, % ssh:connect 返回的句柄
|
|
353
|
+
channels = #{} :: #{binary() => pid()}, % 通道 Pid 映射
|
|
354
|
+
state = idle :: idle|connecting|handshaking|authenticating|ready|reconnect|closed|failed,
|
|
355
|
+
reconnect_count = 0 :: non_neg_integer(),
|
|
356
|
+
options :: [{atom(), term()}] % ssh:connect 选项
|
|
357
|
+
}).
|
|
358
|
+
|
|
359
|
+
%% 核心操作
|
|
360
|
+
-export([connect/1, disconnect/1, open_channel/2, open_sftp/1]).
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**连接建立流程**:
|
|
364
|
+
|
|
365
|
+
```erlang
|
|
366
|
+
%% @doc 建立完整 SSH 连接
|
|
367
|
+
connect(Spec) ->
|
|
368
|
+
{ok, Pid} = ssh_conn_pool_sup:start_child(Spec),
|
|
369
|
+
ssh_conn_worker:do_connect(Pid).
|
|
370
|
+
|
|
371
|
+
%% @doc 内部连接实现
|
|
372
|
+
do_connect(Pid) ->
|
|
373
|
+
gen_server:call(Pid, do_connect, infinity).
|
|
374
|
+
|
|
375
|
+
handle_call(do_connect, _From, #conn{spec = Spec} = State) ->
|
|
376
|
+
Options = build_ssh_options(Spec),
|
|
377
|
+
Host = maps:get(<<"host">>, Spec),
|
|
378
|
+
Port = maps:get(<<"port">>, Spec, 22),
|
|
379
|
+
case ssh:connect(binary_to_list(Host), Port, Options, infinity) of
|
|
380
|
+
{ok, SshRef} ->
|
|
381
|
+
NewState = State#conn{ssh_ref = SshRef, state = ready},
|
|
382
|
+
%% 通知 IPC 网关推送连接就绪事件
|
|
383
|
+
ssh_ipc_gateway:push_event(<<"conn.ready">>, #{id => State#conn.id}),
|
|
384
|
+
{reply, {ok, State#conn.id}, NewState};
|
|
385
|
+
{error, Reason} ->
|
|
386
|
+
{reply, {error, Reason}, State#conn{state = failed}}
|
|
387
|
+
end.
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
**跳板链处理** — FR-CONN-003:
|
|
391
|
+
|
|
392
|
+
```erlang
|
|
393
|
+
%% @doc 构建多级跳板连接
|
|
394
|
+
%% 算法:从最后一级跳板开始反向递归,每级用 ssh:connect + ssh_connection:session_channel
|
|
395
|
+
build_jump_chain(TargetSpec, Jumps) ->
|
|
396
|
+
%% Jumps = [Jump1, Jump2, ...](从近到远)
|
|
397
|
+
%% 1. 连第一级跳板(直接 TCP)
|
|
398
|
+
{ok, Ref1} = direct_connect(hd(Jumps)),
|
|
399
|
+
%% 2. 逐级在上一级 SSH 通道内建立到下一级的 TCP 转发
|
|
400
|
+
Refs = lists:foldl(fun(Jump, Acc) ->
|
|
401
|
+
[ParentRef | _] = Acc,
|
|
402
|
+
{ok, NextRef} = tunneled_connect(ParentRef, Jump),
|
|
403
|
+
[NextRef | Acc]
|
|
404
|
+
end, [Ref1], tl(Jumps)),
|
|
405
|
+
%% 3. 在最后一级跳板上建立到最终目标的 SSH 连接
|
|
406
|
+
{ok, FinalRef} = tunneled_connect(hd(Refs), TargetSpec),
|
|
407
|
+
{ok, FinalRef}.
|
|
408
|
+
|
|
409
|
+
%% @doc 通过已有 SSH 通道建立到目标的 TCP 连接
|
|
410
|
+
tunneled_connect(SshRef, Spec) ->
|
|
411
|
+
Host = binary_to_list(maps:get(<<"host">>, Spec)),
|
|
412
|
+
Port = maps:get(<<"port">>, Spec, 22),
|
|
413
|
+
%% 使用 ssh_connection:direct_tcpip 建立端口转发
|
|
414
|
+
{ok, ChanId} = ssh_connection:direct_tcpip(SshRef, Host, Port, "127.0.0.1", 0, infinity),
|
|
415
|
+
%% 在这个通道上发起新的 SSH 握手
|
|
416
|
+
{ok, NewRef} = ssh:connect_via(ChanId, build_ssh_options(Spec)),
|
|
417
|
+
{ok, NewRef}.
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
#### 4.2.3 ssh_channel_fsm — 通道状态机
|
|
421
|
+
|
|
422
|
+
**职责**:管理单个 SSH 通道(shell/exec/sftp/subsystem)的生命周期与数据流。
|
|
423
|
+
|
|
424
|
+
```erlang
|
|
425
|
+
-module(ssh_channel_fsm).
|
|
426
|
+
-behaviour(gen_fsm).
|
|
427
|
+
|
|
428
|
+
%% 状态
|
|
429
|
+
%% open → 通道已打开,正在协商
|
|
430
|
+
%% ready → 通道就绪,可收发数据
|
|
431
|
+
%% flowing → 数据流动中
|
|
432
|
+
%% closed → 通道关闭
|
|
433
|
+
|
|
434
|
+
%% 事件
|
|
435
|
+
%% {data, Data} → 从 SSH 层收到数据
|
|
436
|
+
%% {send, Data} → Ruby 端要发送数据
|
|
437
|
+
%% close → 关闭通道
|
|
438
|
+
%% {eof, Reason} → 收到 EOF
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
**终端通道数据流**:
|
|
442
|
+
|
|
443
|
+
```erlang
|
|
444
|
+
%% ready 状态下收到 SSH 数据
|
|
445
|
+
ready({data, Data}, State) ->
|
|
446
|
+
%% 推送到 Ruby 端
|
|
447
|
+
ssh_ipc_gateway:push_event(<<"channel.data">>, #{
|
|
448
|
+
id => State#channel.id,
|
|
449
|
+
data => base64:encode(Data)
|
|
450
|
+
}),
|
|
451
|
+
{next_state, flowing, State};
|
|
452
|
+
|
|
453
|
+
%% flowing 状态下继续推送
|
|
454
|
+
flowing({data, Data}, State) ->
|
|
455
|
+
ssh_ipc_gateway:push_event(<<"channel.data">>, #{
|
|
456
|
+
id => State#channel.id,
|
|
457
|
+
data => base64:encode(Data)
|
|
458
|
+
}),
|
|
459
|
+
{next_state, flowing, State};
|
|
460
|
+
|
|
461
|
+
%% Ruby 端发送数据
|
|
462
|
+
flowing({send, Data}, State) ->
|
|
463
|
+
ssh_connection:send(State#channel.ssh_ref, State#channel.chan_id, Data),
|
|
464
|
+
{next_state, flowing, State}.
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
#### 4.2.4 ssh_auth_engine — 认证引擎
|
|
468
|
+
|
|
469
|
+
**职责**:管理 SSH 认证流程,支持多种认证方式和回退链。对应 FR-CONN-002。
|
|
470
|
+
|
|
471
|
+
```erlang
|
|
472
|
+
-module(ssh_auth_engine).
|
|
473
|
+
|
|
474
|
+
%% 认证方式类型
|
|
475
|
+
-type auth_method() :: password | publickey | keyboard_interactive.
|
|
476
|
+
-type auth_result() :: {ok, connected} | {error, term()} | {next_method, auth_method()}.
|
|
477
|
+
|
|
478
|
+
%% @doc 按认证链依次尝试
|
|
479
|
+
%% AuthChain = [publickey, password, keyboard_interactive]
|
|
480
|
+
-spec authenticate(ssh:connection_ref(), [auth_method()], map()) -> auth_result().
|
|
481
|
+
authenticate(Ref, [], _Creds) ->
|
|
482
|
+
{error, all_methods_failed};
|
|
483
|
+
authenticate(Ref, [Method | Rest], Creds) ->
|
|
484
|
+
case do_auth(Ref, Method, Creds) of
|
|
485
|
+
{ok, connected} ->
|
|
486
|
+
{ok, connected};
|
|
487
|
+
{next_method, _} ->
|
|
488
|
+
authenticate(Ref, Rest, Creds);
|
|
489
|
+
{error, _Reason} ->
|
|
490
|
+
authenticate(Ref, Rest, Creds)
|
|
491
|
+
end.
|
|
492
|
+
|
|
493
|
+
%% 公钥认证
|
|
494
|
+
do_auth(Ref, publickey, #{key_path := Path, passphrase := Pass}) ->
|
|
495
|
+
case ssh:load_host_key(Path, Pass) of
|
|
496
|
+
{ok, Key} ->
|
|
497
|
+
ssh:auth_user(Ref, publickey, Key);
|
|
498
|
+
Error ->
|
|
499
|
+
Error
|
|
500
|
+
end;
|
|
501
|
+
|
|
502
|
+
%% 密码认证
|
|
503
|
+
do_auth(Ref, password, #{password := Pass}) ->
|
|
504
|
+
ssh:auth_user(Ref, password, Pass);
|
|
505
|
+
|
|
506
|
+
%% 键盘交互(用于 2FA / OTP 场景)
|
|
507
|
+
do_auth(Ref, keyboard_interactive, #{handler := Handler}) ->
|
|
508
|
+
ssh:auth_user(Ref, keyboard_interactive, #{
|
|
509
|
+
prompt_fun => fun(Prompts) -> Handler(Prompts) end
|
|
510
|
+
}).
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
#### 4.2.5 ssh_keepalive_mgr — 保活与重连
|
|
514
|
+
|
|
515
|
+
**对应需求**:FR-CONN-004(保活)和 FR-CONN-005(自动重连)。
|
|
516
|
+
|
|
517
|
+
```erlang
|
|
518
|
+
-module(ssh_keepalive_mgr).
|
|
519
|
+
-behaviour(gen_server).
|
|
520
|
+
|
|
521
|
+
%% 周期巡检所有活跃连接
|
|
522
|
+
handle_info(tick, State) ->
|
|
523
|
+
lists:foreach(fun(Conn) ->
|
|
524
|
+
case ssh_conn_worker:get_state(Conn) of
|
|
525
|
+
ready ->
|
|
526
|
+
case send_keepalive(Conn) of
|
|
527
|
+
ok -> ok;
|
|
528
|
+
{error, _} ->
|
|
529
|
+
%% 连续失败计数
|
|
530
|
+
increment_fail_count(Conn),
|
|
531
|
+
case get_fail_count(Conn) >= 3 of
|
|
532
|
+
true ->
|
|
533
|
+
ssh_conn_worker:trigger_reconnect(Conn);
|
|
534
|
+
false ->
|
|
535
|
+
ok
|
|
536
|
+
end
|
|
537
|
+
end;
|
|
538
|
+
_ -> ok
|
|
539
|
+
end
|
|
540
|
+
end, ssh_conn_pool_sup:all_workers()),
|
|
541
|
+
erlang:send_after(State#state.interval, self(), tick),
|
|
542
|
+
{noreply, State}.
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
**重连策略(指数退避)**:
|
|
546
|
+
|
|
547
|
+
```
|
|
548
|
+
重连间隔 = base_interval * 2 ^ min(attempt, 6), 上限 60s
|
|
549
|
+
attempt 0: 5s attempt 1: 10s attempt 2: 20s
|
|
550
|
+
attempt 3: 40s attempt 4: 60s attempt 5: 60s ...
|
|
551
|
+
最大尝试 = 配置值(1-10),默认 5
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
#### 4.2.6 ssh_port_fwd — 端口转发
|
|
555
|
+
|
|
556
|
+
**对应需求**:FR-NET-001。
|
|
557
|
+
|
|
558
|
+
```erlang
|
|
559
|
+
-module(ssh_port_fwd).
|
|
560
|
+
|
|
561
|
+
%% 三种转发类型
|
|
562
|
+
-type fwd_type() :: local | remote | dynamic.
|
|
563
|
+
|
|
564
|
+
%% 本地转发:本地端口 → SSH隧道 → 远程目标
|
|
565
|
+
setup_local(SshRef, LocalPort, RemoteHost, RemotePort) ->
|
|
566
|
+
ssh:tcpip_tunnel(SshRef, {0,0,0,0}, LocalPort, RemoteHost, RemotePort, []).
|
|
567
|
+
|
|
568
|
+
%% 远程转发:远程端口 → SSH隧道 → 本地目标
|
|
569
|
+
setup_remote(SshRef, RemotePort, LocalHost, LocalPort) ->
|
|
570
|
+
ssh:tcpip_tunnel(SshRef, {0,0,0,0}, RemotePort, LocalHost, LocalPort, [{remote, true}]).
|
|
571
|
+
|
|
572
|
+
%% 动态转发(SOCKS5):本地端口 → SSH隧道 → 动态目标
|
|
573
|
+
setup_dynamic(SshRef, LocalPort) ->
|
|
574
|
+
%% 需要自行实现 SOCKS5 协商 + direct_tcpip
|
|
575
|
+
ssh_dyn_fwd:start(SshRef, LocalPort).
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
#### 4.2.7 ssh_known_hosts — 已知主机管理
|
|
579
|
+
|
|
580
|
+
**对应需求**:FR-SEC-002。
|
|
581
|
+
|
|
582
|
+
```erlang
|
|
583
|
+
-module(ssh_known_hosts).
|
|
584
|
+
|
|
585
|
+
%% 存储格式:~/.ssh/known_hosts 兼容,额外维护指纹索引
|
|
586
|
+
%% 文件路径可配置,V1.0 使用本地文件,V2.0+ 可选 Ruby 侧加密存储
|
|
587
|
+
|
|
588
|
+
%% @doc 首次连接时验证主机密钥
|
|
589
|
+
verify_host(Host, Port, Key) ->
|
|
590
|
+
case lookup(Host, Port) of
|
|
591
|
+
{ok, StoredKey} when StoredKey == Key ->
|
|
592
|
+
ok;
|
|
593
|
+
{ok, StoredKey} when StoredKey /= Key ->
|
|
594
|
+
{error, host_key_changed};
|
|
595
|
+
not_found ->
|
|
596
|
+
{prompt, Key} %% 通知 Ruby 弹窗确认
|
|
597
|
+
end.
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
#### 4.2.8 ssh_sftp_engine — SFTP 引擎
|
|
601
|
+
|
|
602
|
+
**对应需求**:FR-FILE-001、FR-FILE-002、FR-FILE-005。
|
|
603
|
+
|
|
604
|
+
```erlang
|
|
605
|
+
-module(ssh_sftp_engine).
|
|
606
|
+
|
|
607
|
+
%% 基于 OTP ssh_sftp 通道
|
|
608
|
+
-include_lib("kernel/include/file.hrl").
|
|
609
|
+
|
|
610
|
+
%% 可视化浏览器操作
|
|
611
|
+
open_dir(SshRef, Path) ->
|
|
612
|
+
{ok, Handle} = ssh_sftp: opendir(SshRef, Path),
|
|
613
|
+
{ok, list_dir_entries(SshRef, Handle)}.
|
|
614
|
+
|
|
615
|
+
read_file(SshRef, RemotePath, LocalPath, ProgressFun) ->
|
|
616
|
+
{ok, Handle} = ssh_sftp:open(SshRef, RemotePath, [read, binary]),
|
|
617
|
+
{ok, FileInfo} = ssh_sftp:read_file_info(SshRef, RemotePath),
|
|
618
|
+
transfer(SshRef, Handle, LocalPath, FileInfo#file_info.size, ProgressFun, read).
|
|
619
|
+
|
|
620
|
+
%% 批量传输
|
|
621
|
+
batch_transfer(SshRef, Files, Direction, Concurrency) ->
|
|
622
|
+
%% Concurrency 个并发 channel
|
|
623
|
+
Pool = [open_sftp_channel(SshRef) || _ <- lists:seq(1, Concurrency)],
|
|
624
|
+
Tasks = [{Chan, File, Direction} || File <- Files, Chan <- Pool],
|
|
625
|
+
poolboy:transaction(Tasks, fun(Task) -> do_transfer(Task) end).
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### 4.3 SSH 算法配置
|
|
629
|
+
|
|
630
|
+
对照 FR-CONN-001 量化指标:
|
|
631
|
+
|
|
632
|
+
| 类别 | Erlang 算法配置 |
|
|
633
|
+
|------|----------------|
|
|
634
|
+
| **密钥交换** | `curve25519-sha256@libssh.org`, `curve25519-sha256`, `ecdh-sha2-nistp256`, `ecdh-sha2-nistp384`, `ecdh-sha2-nistp521`, `diffie-hellman-group16-sha512`, `diffie-hellman-group14-sha256`(≥ 7 种) |
|
|
635
|
+
| **公钥** | `ssh-ed25519`, `rsa-sha2-512`, `rsa-sha2-256`, `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`(≥ 6 种) |
|
|
636
|
+
| **对称加密** | `aes256-gcm@openssh.com`, `aes128-gcm@openssh.com`, `chacha20-poly1305@openssh.com`, `aes256-ctr`, `aes192-ctr`, `aes128-ctr`(≥ 6 种) |
|
|
637
|
+
| **MAC** | `hmac-sha2-256`, `hmac-sha2-512`, `hmac-sha2-256-etm@openssh.com`, `hmac-sha2-512-etm@openssh.com`(≥ 4 种) |
|
|
638
|
+
|
|
639
|
+
```erlang
|
|
640
|
+
%% build_ssh_options/1 生成的算法选项
|
|
641
|
+
build_ssh_options(Spec) ->
|
|
642
|
+
[
|
|
643
|
+
{preferred_algorithms, #{
|
|
644
|
+
kex => ['curve25519-sha256@libssh.org',
|
|
645
|
+
'curve25519-sha256',
|
|
646
|
+
'ecdh-sha2-nistp256',
|
|
647
|
+
'ecdh-sha2-nistp384',
|
|
648
|
+
'ecdh-sha2-nistp521',
|
|
649
|
+
'diffie-hellman-group16-sha512',
|
|
650
|
+
'diffie-hellman-group14-sha256'],
|
|
651
|
+
public_key => ['ssh-ed25519',
|
|
652
|
+
'rsa-sha2-512',
|
|
653
|
+
'rsa-sha2-256',
|
|
654
|
+
'ecdsa-sha2-nistp256',
|
|
655
|
+
'ecdsa-sha2-nistp384',
|
|
656
|
+
'ecdsa-sha2-nistp521'],
|
|
657
|
+
cipher => ['aes256-gcm@openssh.com',
|
|
658
|
+
'aes128-gcm@openssh.com',
|
|
659
|
+
'chacha20-poly1305@openssh.com',
|
|
660
|
+
'aes256-ctr','aes192-ctr','aes128-ctr'],
|
|
661
|
+
mac => ['hmac-sha2-256',
|
|
662
|
+
'hmac-sha2-512',
|
|
663
|
+
'hmac-sha2-256-etm@openssh.com',
|
|
664
|
+
'hmac-sha2-512-etm@openssh.com']
|
|
665
|
+
}},
|
|
666
|
+
{user, binary_to_list(maps:get(<<"user">>, Spec))},
|
|
667
|
+
{silently_accept_hosts, false},
|
|
668
|
+
{user_dir, binary_to_list(maps:get(<<"key_dir">>, Spec, <<"/tmp">>))}
|
|
669
|
+
] ++ build_auth_options(Spec).
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### 4.4 连接池管理
|
|
673
|
+
|
|
674
|
+
#### 4.4.1 ssh_conn_pool_sup — 连接池 Supervisor
|
|
675
|
+
|
|
676
|
+
```erlang
|
|
677
|
+
-module(ssh_conn_pool_sup).
|
|
678
|
+
-behaviour(supervisor).
|
|
679
|
+
|
|
680
|
+
%% simple_one_for_one 策略,按需创建连接进程
|
|
681
|
+
init([]) ->
|
|
682
|
+
SupFlags = #{strategy => simple_one_for_one,
|
|
683
|
+
intensity => 100,
|
|
684
|
+
period => 60},
|
|
685
|
+
ChildSpec = #{id => ssh_conn_worker,
|
|
686
|
+
start => {ssh_conn_worker, start_link, []},
|
|
687
|
+
restart => temporary,
|
|
688
|
+
shutdown => 10000,
|
|
689
|
+
type => worker,
|
|
690
|
+
modules => [ssh_conn_worker]},
|
|
691
|
+
{ok, {SupFlags, [ChildSpec]}}.
|
|
692
|
+
|
|
693
|
+
%% 获取所有活跃连接
|
|
694
|
+
all_workers() ->
|
|
695
|
+
[Pid || {_, Pid, _, _} <- supervisor:which_children(?MODULE)].
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
#### 4.4.2 连接 ID 分配
|
|
699
|
+
|
|
700
|
+
```erlang
|
|
701
|
+
%% 短 ID + UUID 后缀,方便调试同时全局唯一
|
|
702
|
+
gen_conn_id() ->
|
|
703
|
+
iolist_to_binary(["conn_", integer_to_list(unix_ts()), "_",
|
|
704
|
+
binary:part(uuid:get_v4(), {0, 8})]).
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
### 4.5 监督树设计
|
|
708
|
+
|
|
709
|
+
```erlang
|
|
710
|
+
%% ssh_core_sup.erl 顶层监督树
|
|
711
|
+
init([]) ->
|
|
712
|
+
SupFlags = #{strategy => one_for_one,
|
|
713
|
+
intensity => 10,
|
|
714
|
+
period => 60},
|
|
715
|
+
Children = [
|
|
716
|
+
#{
|
|
717
|
+
id => ssh_known_hosts,
|
|
718
|
+
start => {ssh_known_hosts, start_link, []},
|
|
719
|
+
restart => permanent,
|
|
720
|
+
type => worker
|
|
721
|
+
},
|
|
722
|
+
#{
|
|
723
|
+
id => ssh_ipc_gateway,
|
|
724
|
+
start => {ssh_ipc_gateway, start_link, [ListenOpts]},
|
|
725
|
+
restart => permanent,
|
|
726
|
+
type => worker
|
|
727
|
+
},
|
|
728
|
+
#{
|
|
729
|
+
id => ssh_conn_pool_sup,
|
|
730
|
+
start => {ssh_conn_pool_sup, start_link, []},
|
|
731
|
+
restart => permanent,
|
|
732
|
+
type => supervisor
|
|
733
|
+
},
|
|
734
|
+
#{
|
|
735
|
+
id => ssh_sftp_engine_sup,
|
|
736
|
+
start => {ssh_sftp_engine_sup, start_link, []},
|
|
737
|
+
restart => permanent,
|
|
738
|
+
type => supervisor
|
|
739
|
+
},
|
|
740
|
+
#{
|
|
741
|
+
id => ssh_keepalive_mgr,
|
|
742
|
+
start => {ssh_keepalive_mgr, start_link, [#{interval => 30000}]},
|
|
743
|
+
restart => permanent,
|
|
744
|
+
type => worker
|
|
745
|
+
}
|
|
746
|
+
],
|
|
747
|
+
{ok, {SupFlags, Children}}.
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
监督树结构:
|
|
751
|
+
|
|
752
|
+
```
|
|
753
|
+
ssh_core_sup (one_for_one, intensity 10/60s)
|
|
754
|
+
├── ssh_known_hosts (permanent worker)
|
|
755
|
+
├── ssh_ipc_gateway (permanent worker)
|
|
756
|
+
├── ssh_conn_pool_sup (permanent supervisor)
|
|
757
|
+
│ └── [动态] ssh_conn_worker × N (temporary, simple_one_for_one)
|
|
758
|
+
│ └── [动态] ssh_channel_fsm (temporary, 子进程)
|
|
759
|
+
├── ssh_sftp_engine_sup (permanent supervisor)
|
|
760
|
+
│ └── [动态] ssh_sftp_session × M (temporary)
|
|
761
|
+
└── ssh_keepalive_mgr (permanent worker)
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
设计要点:
|
|
765
|
+
- 连接工作进程设为 `temporary`,进程退出不自动重启,由 Ruby 端决定是否重连——避免反复冲击目标服务器。
|
|
766
|
+
- IPC 网关设为 `permanent`,崩溃自动重启不影响已有连接。
|
|
767
|
+
- 保活管理器设为 `permanent`,崩溃后重新初始化,重新扫描所有连接。
|
|
768
|
+
|
|
769
|
+
### 4.6 Erlang Release 打包
|
|
770
|
+
|
|
771
|
+
使用 Rebar3 release 打包独立可运行的 Erlang 运行时,跨平台预编译。
|
|
772
|
+
|
|
773
|
+
```
|
|
774
|
+
ssh_core/
|
|
775
|
+
├── rebar.config
|
|
776
|
+
├── apps/
|
|
777
|
+
│ └── ssh_core/
|
|
778
|
+
│ └── src/ (上述所有 .erl)
|
|
779
|
+
└── config/
|
|
780
|
+
├── vm.args % VM 启动参数
|
|
781
|
+
└── sys.config % 应用配置
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
```erlang
|
|
785
|
+
%% rebar.config 核心配置
|
|
786
|
+
{erl_opts, [debug_info]}.
|
|
787
|
+
{deps, [
|
|
788
|
+
{jsx, "3.1.0"} % JSON 解析
|
|
789
|
+
]}.
|
|
790
|
+
|
|
791
|
+
{relx, [
|
|
792
|
+
{release, {ssh_core, "1.0.0"}, [ssh_core, jsx, runtime_tools]},
|
|
793
|
+
{mode, dev},
|
|
794
|
+
{include_erts, true}, % 打包 ERTS,目标机无需装 Erlang
|
|
795
|
+
{include_src, false},
|
|
796
|
+
{overlay, [
|
|
797
|
+
{mkdir, "{{output_dir}}/logs"},
|
|
798
|
+
{copy, "config/vm.args", "{{output_dir}}/releases/1.0.0/vm.args"},
|
|
799
|
+
{copy, "config/sys.config", "{{output_dir}}/releases/1.0.0/sys.config"}
|
|
800
|
+
]}
|
|
801
|
+
]}.
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
跨平台编译矩阵:
|
|
805
|
+
|
|
806
|
+
| 平台 | 编译环境 | 产物 |
|
|
807
|
+
|------|----------|------|
|
|
808
|
+
| Windows x64 | Windows + MSVC + Rebar3 | `ssh_core_1.0.0_windows_x64.zip` |
|
|
809
|
+
| macOS x64/ARM64 | macOS + Clang + Rebar3 | `ssh_core_1.0.0_macos_universal.tar.gz` |
|
|
810
|
+
| Linux x64 | Ubuntu 20.04 + GCC + Rebar3 | `ssh_core_1.0.0_linux_x64.tar.gz` |
|
|
811
|
+
|
|
812
|
+
---
|
|
813
|
+
|
|
814
|
+
## 5 Ruby 调度界面设计
|
|
815
|
+
|
|
816
|
+
### 5.1 模块结构
|
|
817
|
+
|
|
818
|
+
Ruby 侧作为 network-infra-utility gem 的一部分,放在 `service/ssh/` 目录。
|
|
819
|
+
|
|
820
|
+
```
|
|
821
|
+
service/ssh/
|
|
822
|
+
├── design/
|
|
823
|
+
│ ├── SSH连接客户端功能需求文档.md
|
|
824
|
+
│ └── 软件设计文档.md ← 本文档
|
|
825
|
+
├── lib/
|
|
826
|
+
│ └── network_infra_utility/
|
|
827
|
+
│ └── ssh/
|
|
828
|
+
│ ├── version.rb # 版本号
|
|
829
|
+
│ ├── client.rb # 主入口,生命周期管理
|
|
830
|
+
│ ├── ipc/
|
|
831
|
+
│ │ ├── client.rb # JSON-RPC 客户端
|
|
832
|
+
│ │ ├── protocol.rb # JSON-RPC 协议封装
|
|
833
|
+
│ │ └── event_listener.rb # 异步事件监听
|
|
834
|
+
│ ├── session/
|
|
835
|
+
│ │ ├── manager.rb # 会话管理器
|
|
836
|
+
│ │ ├── session.rb # 单个会话对象
|
|
837
|
+
│ │ ├── tree.rb # 会话树形分组
|
|
838
|
+
│ │ └── history.rb # 最近连接与历史
|
|
839
|
+
│ ├── terminal/
|
|
840
|
+
│ │ ├── emulator.rb # ANSI/xterm 转义解析
|
|
841
|
+
│ │ ├── renderer.rb # 屏幕渲染
|
|
842
|
+
│ │ ├── buffer.rb # 回滚缓冲区
|
|
843
|
+
│ │ ├── theme.rb # 配色方案
|
|
844
|
+
│ │ └── input.rb # 输入处理
|
|
845
|
+
│ ├── file/
|
|
846
|
+
│ │ ├── sftp_browser.rb # SFTP 浏览器
|
|
847
|
+
│ │ ├── transfer.rb # 文件传输管理
|
|
848
|
+
│ │ └── watcher.rb # 远程文件编辑监听
|
|
849
|
+
│ ├── security/
|
|
850
|
+
│ │ ├── vault.rb # 凭据加密存储
|
|
851
|
+
│ │ ├── key_manager.rb # 密钥管理
|
|
852
|
+
│ │ └── host_key.rb # 主机密钥校验
|
|
853
|
+
│ ├── automation/
|
|
854
|
+
│ │ ├── macro_engine.rb # 登录宏引擎
|
|
855
|
+
│ │ ├── batch_exec.rb # 批量执行
|
|
856
|
+
│ │ └── snippet_manager.rb# 代码片段管理
|
|
857
|
+
│ ├── network/
|
|
858
|
+
│ │ ├── port_forward.rb # 端口转发规则
|
|
859
|
+
│ │ ├── proxy.rb # 代理配置
|
|
860
|
+
│ │ └── diagnostics.rb # 连接诊断
|
|
861
|
+
│ └── config/
|
|
862
|
+
│ ├── settings.rb # 全局设置
|
|
863
|
+
│ ├── import_export.rb # 会话导入导出
|
|
864
|
+
│ └── schema.rb # 配置数据模型
|
|
865
|
+
├── bin/
|
|
866
|
+
│ └── ssh-client # CLI 入口脚本
|
|
867
|
+
├── spec/
|
|
868
|
+
│ ├── ipc/ # IPC 通信测试
|
|
869
|
+
│ ├── session/ # 会话管理测试
|
|
870
|
+
│ ├── terminal/ # 终端仿真测试
|
|
871
|
+
│ └── ...
|
|
872
|
+
└── ssh_client.gemspec
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
### 5.2 核心模块设计
|
|
876
|
+
|
|
877
|
+
#### 5.2.1 SSH::Client — 主入口
|
|
878
|
+
|
|
879
|
+
```ruby
|
|
880
|
+
# frozen_string_literal: true
|
|
881
|
+
|
|
882
|
+
require_relative "ipc/client"
|
|
883
|
+
require_relative "session/manager"
|
|
884
|
+
require_relative "terminal/emulator"
|
|
885
|
+
require_relative "security/vault"
|
|
886
|
+
require_relative "config/settings"
|
|
887
|
+
|
|
888
|
+
module NetworkInfraUtility
|
|
889
|
+
module SSH
|
|
890
|
+
# SSH 客户端主入口,管理 Erlang 引擎生命周期与模块协调。
|
|
891
|
+
#
|
|
892
|
+
# 用法:
|
|
893
|
+
# client = SSH::Client.new
|
|
894
|
+
# client.start_engine # 启动 Erlang 后端
|
|
895
|
+
# session = client.connect(host: "10.0.0.1", user: "admin")
|
|
896
|
+
# session.terminal.puts("show version")
|
|
897
|
+
class Client
|
|
898
|
+
attr_reader :ipc, :sessions, :vault, :settings
|
|
899
|
+
|
|
900
|
+
ENGINE_BINARY = File.expand_path("../ext/ssh_core/bin/ssh_core", __dir__)
|
|
901
|
+
ENGINE_STARTUP_TIMEOUT = 10 # 秒
|
|
902
|
+
|
|
903
|
+
def initialize
|
|
904
|
+
@settings = Config::Settings.new
|
|
905
|
+
@vault = Security::Vault.new(@settings.master_password)
|
|
906
|
+
@sessions = Session::Manager.new(self)
|
|
907
|
+
@ipc = IPC::Client.new
|
|
908
|
+
@engine_pid = nil
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
# 启动 Erlang SSH 核心引擎,建立 IPC 连接。
|
|
912
|
+
def start_engine
|
|
913
|
+
@engine_pid = spawn_engine
|
|
914
|
+
wait_for_ipcReady(ENGINE_STARTUP_TIMEOUT)
|
|
915
|
+
@ipc.connect(read_ipc_endpoint)
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
# 发起 SSH 连接。
|
|
919
|
+
# @param spec [Hash] 连接参数 {host, port, user, auth, jumps, ...}
|
|
920
|
+
# @return [Session] 会话对象
|
|
921
|
+
def connect(spec)
|
|
922
|
+
spec = @vault.resolve_credentials(spec)
|
|
923
|
+
conn_id = @ipc.call("conn.connect", spec)
|
|
924
|
+
@sessions.create(conn_id, spec)
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def stop
|
|
928
|
+
@sessions.disconnect_all
|
|
929
|
+
@ipc.close
|
|
930
|
+
Process.kill("TERM", @engine_pid) if @engine_pid
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
private
|
|
934
|
+
|
|
935
|
+
def spawn_engine
|
|
936
|
+
log_dir = @settings.log_dir
|
|
937
|
+
FileUtils.mkdir_p(log_dir)
|
|
938
|
+
Process.spawn(
|
|
939
|
+
ENGINE_BINARY, "foreground",
|
|
940
|
+
"--config", @settings.config_path,
|
|
941
|
+
out: File.join(log_dir, "engine.log"),
|
|
942
|
+
err: File.join(log_dir, "engine.err")
|
|
943
|
+
)
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
# Erlang 引擎启动时将 IPC 端口写入临时文件,Ruby 读取。
|
|
947
|
+
def read_ipc_endpoint
|
|
948
|
+
endpoint_file = File.join(Dir.tmpdir, "ssh_core_#{Process.uid}.endpoint")
|
|
949
|
+
deadline = Time.now + ENGINE_STARTUP_TIMEOUT
|
|
950
|
+
loop do
|
|
951
|
+
return File.read(endpoint_file).strip if File.exist?(endpoint_file)
|
|
952
|
+
raise "Erlang engine startup timeout" if Time.now > deadline
|
|
953
|
+
sleep 0.1
|
|
954
|
+
end
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
def wait_for_ipc_ready(timeout)
|
|
958
|
+
deadline = Time.now + timeout
|
|
959
|
+
loop do
|
|
960
|
+
return if @ipc.connected?
|
|
961
|
+
raise "IPC connection timeout" if Time.now > deadline
|
|
962
|
+
sleep 0.1
|
|
963
|
+
end
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
end
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
#### 5.2.2 IPC::Client — JSON-RPC 客户端
|
|
971
|
+
|
|
972
|
+
```ruby
|
|
973
|
+
# frozen_string_literal: true
|
|
974
|
+
|
|
975
|
+
require "json"
|
|
976
|
+
require "socket"
|
|
977
|
+
|
|
978
|
+
module NetworkInfraUtility
|
|
979
|
+
module SSH
|
|
980
|
+
module IPC
|
|
981
|
+
# JSON-RPC 2.0 客户端,与 Erlang SSH 核心引擎通信。
|
|
982
|
+
#
|
|
983
|
+
# 同步调用用 #call,异步事件推送通过 #on_event 注册回调。
|
|
984
|
+
class Client
|
|
985
|
+
attr_reader :connected
|
|
986
|
+
|
|
987
|
+
def initialize
|
|
988
|
+
@socket = nil
|
|
989
|
+
@request_id = 0
|
|
990
|
+
@pending = {} # id => Queue
|
|
991
|
+
@callbacks = {} # event_name => [Proc]
|
|
992
|
+
@listener_thread = nil
|
|
993
|
+
@connected = false
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
# 连接到 Erlang IPC 网关。
|
|
997
|
+
# @param endpoint [String] "unix:/path/to/sock" 或 "tcp://127.0.0.1:port"
|
|
998
|
+
def connect(endpoint)
|
|
999
|
+
@socket = open_socket(endpoint)
|
|
1000
|
+
@connected = true
|
|
1001
|
+
start_event_listener
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
# 同步 RPC 调用。
|
|
1005
|
+
# @param method [String] 方法名,如 "conn.connect"
|
|
1006
|
+
# @param params [Hash] 参数
|
|
1007
|
+
# @param timeout [Integer] 超时秒数
|
|
1008
|
+
# @return 调用结果
|
|
1009
|
+
# @raise [RPCError] 远程返回错误
|
|
1010
|
+
def call(method, params = {}, timeout = 30)
|
|
1011
|
+
id = next_request_id
|
|
1012
|
+
request = {jsonrpc: "2.0", id: id, method: method, params: params}
|
|
1013
|
+
queue = Queue.new
|
|
1014
|
+
@pending[id] = queue
|
|
1015
|
+
@socket.puts(JSON.generate(request))
|
|
1016
|
+
result = queue.pop(timeout: timeout)
|
|
1017
|
+
raise RPCError, result[:error] if result[:error]
|
|
1018
|
+
result[:result]
|
|
1019
|
+
ensure
|
|
1020
|
+
@pending.delete(id)
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
# 注册异步事件回调。
|
|
1024
|
+
# @param event [String] 事件名,如 "channel.data"
|
|
1025
|
+
# @param block [Proc] 回调
|
|
1026
|
+
def on_event(event, &block)
|
|
1027
|
+
(@callbacks[event] ||= []) << block
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
def close
|
|
1031
|
+
@connected = false
|
|
1032
|
+
@listener_thread&.kill
|
|
1033
|
+
@socket&.close
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
private
|
|
1037
|
+
|
|
1038
|
+
def open_socket(endpoint)
|
|
1039
|
+
if endpoint.start_with?("unix:")
|
|
1040
|
+
UNIXSocket.new(endpoint.sub("unix:", ""))
|
|
1041
|
+
elsif endpoint.start_with?("tcp://")
|
|
1042
|
+
uri = URI(endpoint)
|
|
1043
|
+
TCPSocket.new(uri.host, uri.port)
|
|
1044
|
+
else
|
|
1045
|
+
raise ArgumentError, "Unknown endpoint format: #{endpoint}"
|
|
1046
|
+
end
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
def start_event_listener
|
|
1050
|
+
@listener_thread = Thread.new do
|
|
1051
|
+
loop do
|
|
1052
|
+
line = @socket.gets
|
|
1053
|
+
break unless line
|
|
1054
|
+
msg = JSON.parse(line, symbolize_names: true)
|
|
1055
|
+
handle_message(msg)
|
|
1056
|
+
end
|
|
1057
|
+
end
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
def handle_message(msg)
|
|
1061
|
+
if msg[:id] && @pending[msg[:id]]
|
|
1062
|
+
# 同步响应
|
|
1063
|
+
@pending[msg[:id]] << msg
|
|
1064
|
+
elsif msg[:method]
|
|
1065
|
+
# 异步推送
|
|
1066
|
+
callbacks = @callbacks[msg[:method]]
|
|
1067
|
+
callbacks&.each { |cb| cb.call(msg[:params]) }
|
|
1068
|
+
end
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
def next_request_id
|
|
1072
|
+
@request_id += 1
|
|
1073
|
+
end
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
class RPCError < StandardError; end
|
|
1077
|
+
end
|
|
1078
|
+
end
|
|
1079
|
+
end
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
#### 5.2.3 Session::Manager — 会话管理器
|
|
1083
|
+
|
|
1084
|
+
```ruby
|
|
1085
|
+
# frozen_string_literal: true
|
|
1086
|
+
|
|
1087
|
+
module NetworkInfraUtility
|
|
1088
|
+
module SSH
|
|
1089
|
+
module Session
|
|
1090
|
+
# 会话管理器,维护全部活跃会话的生命周期。
|
|
1091
|
+
# 对应需求 FR-SESS-001 ~ FR-SESS-006。
|
|
1092
|
+
class Manager
|
|
1093
|
+
include Enumerable
|
|
1094
|
+
|
|
1095
|
+
def initialize(client)
|
|
1096
|
+
@client = client
|
|
1097
|
+
@sessions = {} # conn_id => Session
|
|
1098
|
+
@tree = Tree.new # 分组树
|
|
1099
|
+
@history = History.new
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
# 发起连接并创建会话。
|
|
1103
|
+
def connect(spec)
|
|
1104
|
+
conn_id = @client.ipc.call("conn.connect", build_connect_spec(spec))
|
|
1105
|
+
create(conn_id, spec)
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
# 从已有连接 ID 创建会话对象。
|
|
1109
|
+
def create(conn_id, spec)
|
|
1110
|
+
session = Session.new(@client, conn_id, spec)
|
|
1111
|
+
@sessions[conn_id] = session
|
|
1112
|
+
@history.record(session)
|
|
1113
|
+
session
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1116
|
+
def get(conn_id)
|
|
1117
|
+
@sessions[conn_id]
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
def disconnect(conn_id)
|
|
1121
|
+
@sessions[conn_id]&.disconnect
|
|
1122
|
+
@sessions.delete(conn_id)
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
def disconnect_all
|
|
1126
|
+
@sessions.each_value(&:disconnect)
|
|
1127
|
+
@sessions.clear
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
def each(&block)
|
|
1131
|
+
@sessions.each_value(&block)
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
# 搜索会话。对应 FR-SESS-004。
|
|
1135
|
+
# @param query [String] 搜索关键词
|
|
1136
|
+
# @param fields [Array<Symbol>] 搜索字段,默认 :name, :host, :ip, :tags
|
|
1137
|
+
def search(query, fields = %i[name host ip tags])
|
|
1138
|
+
query_lower = query.downcase
|
|
1139
|
+
select do |s|
|
|
1140
|
+
fields.any? do |f|
|
|
1141
|
+
val = s.send(f)
|
|
1142
|
+
val.is_a?(Array) ? val.any? { |v| v.downcase.include?(query_lower) }
|
|
1143
|
+
: val.to_s.downcase.include?(query_lower)
|
|
1144
|
+
end
|
|
1145
|
+
end
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
# 广播输入到多个会话。对应 FR-TERM-008。
|
|
1149
|
+
# @param conn_ids [Array<String>] 目标连接 ID
|
|
1150
|
+
# @param text [String] 要发送的文本
|
|
1151
|
+
def broadcast(conn_ids, text)
|
|
1152
|
+
conn_ids.each { |id| @sessions[id]&.terminal&.send(text) }
|
|
1153
|
+
end
|
|
1154
|
+
|
|
1155
|
+
attr_reader :tree, :history
|
|
1156
|
+
end
|
|
1157
|
+
end
|
|
1158
|
+
end
|
|
1159
|
+
end
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
#### 5.2.4 Session — 会话对象
|
|
1163
|
+
|
|
1164
|
+
```ruby
|
|
1165
|
+
# frozen_string_literal: true
|
|
1166
|
+
|
|
1167
|
+
module NetworkInfraUtility
|
|
1168
|
+
module SSH
|
|
1169
|
+
module Session
|
|
1170
|
+
# 单个 SSH 会话,封装终端、文件、端口转发等子能力。
|
|
1171
|
+
class Session
|
|
1172
|
+
attr_reader :conn_id, :spec, :name, :host, :port, :user
|
|
1173
|
+
attr_reader :terminal, :file_manager, :port_forward
|
|
1174
|
+
attr_accessor :tags, :group
|
|
1175
|
+
|
|
1176
|
+
# 会话状态
|
|
1177
|
+
STATUSES = %i[connecting authenticating connected disconnected error].freeze
|
|
1178
|
+
|
|
1179
|
+
def initialize(client, conn_id, spec)
|
|
1180
|
+
@client = client
|
|
1181
|
+
@conn_id = conn_id
|
|
1182
|
+
@spec = spec
|
|
1183
|
+
@name = spec[:name] || "#{spec[:user]}@#{spec[:host]}"
|
|
1184
|
+
@host = spec[:host]
|
|
1185
|
+
@port = spec[:port] || 22
|
|
1186
|
+
@user = spec[:user]
|
|
1187
|
+
@tags = spec[:tags] || []
|
|
1188
|
+
@group = spec[:group]
|
|
1189
|
+
@status = :connected
|
|
1190
|
+
@terminal = nil
|
|
1191
|
+
@file_manager = nil
|
|
1192
|
+
@port_forward = nil
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
# 打开终端通道。
|
|
1196
|
+
def open_terminal(term_type = "xterm-256color", cols = 80, rows = 24)
|
|
1197
|
+
channel_id = @client.ipc.call("channel.open", {
|
|
1198
|
+
conn_id: @conn_id,
|
|
1199
|
+
type: "shell",
|
|
1200
|
+
term: term_type,
|
|
1201
|
+
cols: cols,
|
|
1202
|
+
rows: rows
|
|
1203
|
+
})
|
|
1204
|
+
@terminal = Terminal::Emulator.new(@client, @conn_id, channel_id, cols, rows)
|
|
1205
|
+
@terminal
|
|
1206
|
+
end
|
|
1207
|
+
|
|
1208
|
+
# 打开 SFTP 文件管理器。对应 FR-FILE-001。
|
|
1209
|
+
def open_file_manager
|
|
1210
|
+
sftp_id = @client.ipc.call("sftp.open", {conn_id: @conn_id})
|
|
1211
|
+
@file_manager = File::SftpBrowser.new(@client, sftp_id)
|
|
1212
|
+
@file_manager
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1215
|
+
# 添加端口转发规则。对应 FR-NET-001。
|
|
1216
|
+
def add_port_forward(type, local_port, remote_host, remote_port)
|
|
1217
|
+
rule_id = @client.ipc.call("portfwd.add", {
|
|
1218
|
+
conn_id: @conn_id,
|
|
1219
|
+
type: type.to_s,
|
|
1220
|
+
local_port: local_port,
|
|
1221
|
+
remote_host: remote_host,
|
|
1222
|
+
remote_port: remote_port
|
|
1223
|
+
})
|
|
1224
|
+
(@port_forward ||= []) << {id: rule_id, type: type, local_port: local_port,
|
|
1225
|
+
remote_host: remote_host, remote_port: remote_port}
|
|
1226
|
+
rule_id
|
|
1227
|
+
end
|
|
1228
|
+
|
|
1229
|
+
def disconnect
|
|
1230
|
+
@client.ipc.call("conn.disconnect", {id: @conn_id})
|
|
1231
|
+
@status = :disconnected
|
|
1232
|
+
end
|
|
1233
|
+
|
|
1234
|
+
def connected?
|
|
1235
|
+
@status == :connected
|
|
1236
|
+
end
|
|
1237
|
+
end
|
|
1238
|
+
end
|
|
1239
|
+
end
|
|
1240
|
+
end
|
|
1241
|
+
```
|
|
1242
|
+
|
|
1243
|
+
#### 5.2.5 Terminal::Emulator — 终端仿真器
|
|
1244
|
+
|
|
1245
|
+
```ruby
|
|
1246
|
+
# frozen_string_literal: true
|
|
1247
|
+
|
|
1248
|
+
module NetworkInfraUtility
|
|
1249
|
+
module SSH
|
|
1250
|
+
module Terminal
|
|
1251
|
+
# ANSI/xterm 转义序列解析与终端状态维护。
|
|
1252
|
+
# 对应需求 FR-TERM-001、FR-TERM-002、FR-TERM-007、FR-TERM-010。
|
|
1253
|
+
class Emulator
|
|
1254
|
+
attr_reader :cols, :rows, :cursor_x, :cursor_y, :buffer, :theme
|
|
1255
|
+
|
|
1256
|
+
# 终端类型映射
|
|
1257
|
+
TERM_TYPES = {
|
|
1258
|
+
"xterm-256color" => {colors: 256, truecolor: true},
|
|
1259
|
+
"vt100" => {colors: 16, truecolor: false},
|
|
1260
|
+
"vt220" => {colors: 16, truecolor: false}
|
|
1261
|
+
}.freeze
|
|
1262
|
+
|
|
1263
|
+
def initialize(client, conn_id, channel_id, cols, rows)
|
|
1264
|
+
@client = client
|
|
1265
|
+
@conn_id = conn_id
|
|
1266
|
+
@channel_id = channel_id
|
|
1267
|
+
@cols = cols
|
|
1268
|
+
@rows = rows
|
|
1269
|
+
@cursor_x = 0
|
|
1270
|
+
@cursor_y = 0
|
|
1271
|
+
@buffer = Buffer.new(rows, max_lines: 10000) # FR-TERM-007
|
|
1272
|
+
@theme = Theme.default
|
|
1273
|
+
@parser = AnsiParser.new(@buffer, @theme)
|
|
1274
|
+
|
|
1275
|
+
# 注册 SSH 数据推送回调
|
|
1276
|
+
@client.ipc.on_event("channel.data") do |params|
|
|
1277
|
+
next unless params[:id] == @channel_id
|
|
1278
|
+
data = Base64.decode64(params[:data])
|
|
1279
|
+
@parser.feed(data)
|
|
1280
|
+
end
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1283
|
+
# 发送数据到 SSH 通道。
|
|
1284
|
+
def send(data)
|
|
1285
|
+
@client.ipc.call("channel.send", {
|
|
1286
|
+
id: @channel_id,
|
|
1287
|
+
data: Base64.encode64(data)
|
|
1288
|
+
})
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1291
|
+
# 发送一行(带换行)。
|
|
1292
|
+
def puts(text)
|
|
1293
|
+
send("#{text}\r")
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
# 窗口大小变更。
|
|
1297
|
+
def resize(cols, rows)
|
|
1298
|
+
@cols = cols
|
|
1299
|
+
@rows = rows
|
|
1300
|
+
@buffer.resize(cols, rows)
|
|
1301
|
+
@client.ipc.call("channel.window_change", {
|
|
1302
|
+
id: @channel_id, cols: cols, rows: rows
|
|
1303
|
+
})
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
# 设置配色主题。对应 FR-TERM-002。
|
|
1307
|
+
def theme=(theme_name)
|
|
1308
|
+
@theme = Theme.load(theme_name)
|
|
1309
|
+
end
|
|
1310
|
+
|
|
1311
|
+
# 搜索缓冲区内容。对应 FR-TERM-005、FR-TERM-007。
|
|
1312
|
+
# @param pattern [String] 正则或关键词
|
|
1313
|
+
# @return [Array<Match>] 匹配结果
|
|
1314
|
+
def search(pattern)
|
|
1315
|
+
@buffer.search(pattern)
|
|
1316
|
+
end
|
|
1317
|
+
|
|
1318
|
+
# 导出缓冲区内容为文本。
|
|
1319
|
+
def export(path = nil)
|
|
1320
|
+
text = @buffer.to_text
|
|
1321
|
+
File.write(path, text) if path
|
|
1322
|
+
text
|
|
1323
|
+
end
|
|
1324
|
+
end
|
|
1325
|
+
end
|
|
1326
|
+
end
|
|
1327
|
+
end
|
|
1328
|
+
```
|
|
1329
|
+
|
|
1330
|
+
#### 5.2.6 Terminal::Buffer — 回滚缓冲区
|
|
1331
|
+
|
|
1332
|
+
```ruby
|
|
1333
|
+
# frozen_string_literal: true
|
|
1334
|
+
|
|
1335
|
+
module NetworkInfraUtility
|
|
1336
|
+
module SSH
|
|
1337
|
+
module Terminal
|
|
1338
|
+
# 终端回滚缓冲区,支持大容量历史和快速搜索。
|
|
1339
|
+
# 对应需求 FR-TERM-007:默认 ≥ 10000 行,可配置 1000–100000。
|
|
1340
|
+
class Buffer
|
|
1341
|
+
attr_reader :rows, :cols, :scrollback_lines
|
|
1342
|
+
|
|
1343
|
+
def initialize(rows, max_lines: 10000)
|
|
1344
|
+
@rows = rows
|
|
1345
|
+
@cols = cols || 80
|
|
1346
|
+
@scrollback_lines = max_lines
|
|
1347
|
+
@lines = [] # 回滚行(已滚出屏幕)
|
|
1348
|
+
@screen = [] # 当前屏幕行
|
|
1349
|
+
@rows.times { @screen << Line.new(@cols) }
|
|
1350
|
+
end
|
|
1351
|
+
|
|
1352
|
+
# 写入字符到当前光标位置(由 AnsiParser 调用)。
|
|
1353
|
+
def write_char(char, x, y, style = {})
|
|
1354
|
+
line = @screen[y] || (@screen[y] = Line.new(@cols))
|
|
1355
|
+
line.write_char(char, x, style)
|
|
1356
|
+
end
|
|
1357
|
+
|
|
1358
|
+
# 换行——将屏幕首行移入回滚区。
|
|
1359
|
+
def newline
|
|
1360
|
+
@lines << @screen.shift
|
|
1361
|
+
@screen << Line.new(@cols)
|
|
1362
|
+
trim_scrollback
|
|
1363
|
+
end
|
|
1364
|
+
|
|
1365
|
+
# 回滚行数 + 屏幕行数。
|
|
1366
|
+
def total_lines
|
|
1367
|
+
@lines.size + @screen.size
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1370
|
+
# 搜索全部内容(回滚 + 屏幕)。
|
|
1371
|
+
# @param pattern [String] 正则表达式
|
|
1372
|
+
# @return [Array<Match>]
|
|
1373
|
+
def search(pattern)
|
|
1374
|
+
regex = Regexp.new(pattern)
|
|
1375
|
+
matches = []
|
|
1376
|
+
all_lines.each_with_index do |line, idx|
|
|
1377
|
+
line.to_s.scan(regex) do
|
|
1378
|
+
m = Regexp.last_match
|
|
1379
|
+
matches << Match.new(line: idx, start: m.begin(0), end: m.end(0), text: m[0])
|
|
1380
|
+
end
|
|
1381
|
+
end
|
|
1382
|
+
matches
|
|
1383
|
+
end
|
|
1384
|
+
|
|
1385
|
+
# 导出纯文本。
|
|
1386
|
+
def to_text
|
|
1387
|
+
all_lines.map(&:to_s).join("\n")
|
|
1388
|
+
end
|
|
1389
|
+
|
|
1390
|
+
def resize(cols, rows)
|
|
1391
|
+
@cols = cols
|
|
1392
|
+
if rows > @rows
|
|
1393
|
+
(rows - @rows).times { @screen << Line.new(@cols) }
|
|
1394
|
+
elsif rows < @rows
|
|
1395
|
+
(@rows - rows).times { @lines << @screen.shift }
|
|
1396
|
+
trim_scrollback
|
|
1397
|
+
end
|
|
1398
|
+
@rows = rows
|
|
1399
|
+
end
|
|
1400
|
+
|
|
1401
|
+
private
|
|
1402
|
+
|
|
1403
|
+
def trim_scrollback
|
|
1404
|
+
while @lines.size > @scrollback_lines
|
|
1405
|
+
@lines.shift
|
|
1406
|
+
end
|
|
1407
|
+
end
|
|
1408
|
+
|
|
1409
|
+
def all_lines
|
|
1410
|
+
@lines + @screen
|
|
1411
|
+
end
|
|
1412
|
+
end
|
|
1413
|
+
|
|
1414
|
+
# 一行字符,每个字符附带样式信息。
|
|
1415
|
+
class Line
|
|
1416
|
+
attr_reader :cells
|
|
1417
|
+
|
|
1418
|
+
def initialize(cols)
|
|
1419
|
+
@cells = Array.new(cols) { Cell.new }
|
|
1420
|
+
end
|
|
1421
|
+
|
|
1422
|
+
def write_char(char, x, style = {})
|
|
1423
|
+
@cells[x] = Cell.new(char, style) if x < @cells.size
|
|
1424
|
+
end
|
|
1425
|
+
|
|
1426
|
+
def to_s
|
|
1427
|
+
@cells.map(&:char).join.rstrip
|
|
1428
|
+
end
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1431
|
+
# 单个字符单元。
|
|
1432
|
+
Cell = Struct.new(:char, :style) do
|
|
1433
|
+
def initialize(char = " ", style = {})
|
|
1434
|
+
super
|
|
1435
|
+
end
|
|
1436
|
+
end
|
|
1437
|
+
|
|
1438
|
+
# 搜索匹配结果。
|
|
1439
|
+
Match = Struct.new(:line, :start, :end, :text)
|
|
1440
|
+
end
|
|
1441
|
+
end
|
|
1442
|
+
end
|
|
1443
|
+
```
|
|
1444
|
+
|
|
1445
|
+
#### 5.2.7 Security::Vault — 凭据加密存储
|
|
1446
|
+
|
|
1447
|
+
```ruby
|
|
1448
|
+
# frozen_string_literal: true
|
|
1449
|
+
|
|
1450
|
+
require "openssl"
|
|
1451
|
+
require "yaml"
|
|
1452
|
+
require "securerandom"
|
|
1453
|
+
|
|
1454
|
+
module NetworkInfraUtility
|
|
1455
|
+
module SSH
|
|
1456
|
+
module Security
|
|
1457
|
+
# 凭据加密存储,AES-256-GCM。
|
|
1458
|
+
# 对应需求 FR-SEC-001。
|
|
1459
|
+
class Vault
|
|
1460
|
+
PBKDF2_ITERATIONS = 100_000
|
|
1461
|
+
SALT_LENGTH = 32
|
|
1462
|
+
KEY_LENGTH = 32 # AES-256
|
|
1463
|
+
NONCE_LENGTH = 12 # GCM nonce
|
|
1464
|
+
|
|
1465
|
+
def initialize(master_password)
|
|
1466
|
+
@master_password = master_password
|
|
1467
|
+
@cache = {} # 解密后的凭据缓存
|
|
1468
|
+
end
|
|
1469
|
+
|
|
1470
|
+
# 加密凭据并存储到配置文件。
|
|
1471
|
+
# @param key [String] 凭据标识,如 "session_001_password"
|
|
1472
|
+
# @param value [String] 明文凭据
|
|
1473
|
+
def store(key, value)
|
|
1474
|
+
salt = SecureRandom.random_bytes(SALT_LENGTH)
|
|
1475
|
+
derived_key = derive_key(@master_password, salt)
|
|
1476
|
+
nonce = SecureRandom.random_bytes(NONCE_LENGTH)
|
|
1477
|
+
cipher = OpenSSL::Cipher::AES.new(256, :GCM)
|
|
1478
|
+
cipher.encrypt
|
|
1479
|
+
cipher.key = derived_key
|
|
1480
|
+
cipher.iv = nonce
|
|
1481
|
+
encrypted = cipher.update(value) + cipher.final
|
|
1482
|
+
tag = cipher.auth_tag
|
|
1483
|
+
|
|
1484
|
+
entry = {
|
|
1485
|
+
salt: Base64.encode64(salt),
|
|
1486
|
+
nonce: Base64.encode64(nonce),
|
|
1487
|
+
tag: Base64.encode64(tag),
|
|
1488
|
+
data: Base64.encode64(encrypted)
|
|
1489
|
+
}
|
|
1490
|
+
write_to_store(key, entry)
|
|
1491
|
+
end
|
|
1492
|
+
|
|
1493
|
+
# 读取并解密凭据。
|
|
1494
|
+
# @param key [String] 凭据标识
|
|
1495
|
+
# @return [String] 明文凭据
|
|
1496
|
+
def load(key)
|
|
1497
|
+
return @cache[key] if @cache[key]
|
|
1498
|
+
|
|
1499
|
+
entry = read_from_store(key)
|
|
1500
|
+
return nil unless entry
|
|
1501
|
+
|
|
1502
|
+
salt = Base64.decode64(entry[:salt])
|
|
1503
|
+
derived_key = derive_key(@master_password, salt)
|
|
1504
|
+
nonce = Base64.decode64(entry[:nonce])
|
|
1505
|
+
tag = Base64.decode64(entry[:tag])
|
|
1506
|
+
encrypted = Base64.decode64(entry[:data])
|
|
1507
|
+
|
|
1508
|
+
cipher = OpenSSL::Cipher::AES.new(256, :GCM)
|
|
1509
|
+
cipher.decrypt
|
|
1510
|
+
cipher.key = derived_key
|
|
1511
|
+
cipher.iv = nonce
|
|
1512
|
+
cipher.auth_tag = tag
|
|
1513
|
+
|
|
1514
|
+
plaintext = cipher.update(encrypted) + cipher.final
|
|
1515
|
+
@cache[key] = plaintext
|
|
1516
|
+
plaintext
|
|
1517
|
+
end
|
|
1518
|
+
|
|
1519
|
+
# 在连接规格中解析凭据引用。
|
|
1520
|
+
# spec 中 password 可能是 "~vault:session_001_password"
|
|
1521
|
+
def resolve_credentials(spec)
|
|
1522
|
+
spec.each do |k, v|
|
|
1523
|
+
if v.is_a?(String) && v.start_with?("~vault:")
|
|
1524
|
+
spec[k] = load(v.sub("~vault:", ""))
|
|
1525
|
+
end
|
|
1526
|
+
end
|
|
1527
|
+
spec
|
|
1528
|
+
end
|
|
1529
|
+
|
|
1530
|
+
private
|
|
1531
|
+
|
|
1532
|
+
def derive_key(password, salt)
|
|
1533
|
+
OpenSSL::PKCS5.pbkdf2_hmac(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256")
|
|
1534
|
+
end
|
|
1535
|
+
|
|
1536
|
+
def write_to_store(key, entry)
|
|
1537
|
+
store = load_store
|
|
1538
|
+
store[key] = entry
|
|
1539
|
+
File.write(store_path, YAML.dump(store))
|
|
1540
|
+
set_file_permissions
|
|
1541
|
+
end
|
|
1542
|
+
|
|
1543
|
+
def read_from_store(key)
|
|
1544
|
+
load_store[key]
|
|
1545
|
+
end
|
|
1546
|
+
|
|
1547
|
+
def load_store
|
|
1548
|
+
return {} unless File.exist?(store_path)
|
|
1549
|
+
YAML.load_file(store_path)
|
|
1550
|
+
end
|
|
1551
|
+
|
|
1552
|
+
def store_path
|
|
1553
|
+
File.join(Dir.home, ".network-infra-utility", "vault.yml")
|
|
1554
|
+
end
|
|
1555
|
+
|
|
1556
|
+
def set_file_permissions
|
|
1557
|
+
path = store_path
|
|
1558
|
+
if RUBY_PLATFORM.include?("mswin") || RUBY_PLATFORM.include?("mingw")
|
|
1559
|
+
# Windows: ACL 限制当前用户
|
|
1560
|
+
system("icacls \"#{path}\" /inheritance:r /grant:r \"#{ENV['USERNAME']}:R\"")
|
|
1561
|
+
else
|
|
1562
|
+
File.chmod(0o600, path)
|
|
1563
|
+
end
|
|
1564
|
+
end
|
|
1565
|
+
end
|
|
1566
|
+
end
|
|
1567
|
+
end
|
|
1568
|
+
end
|
|
1569
|
+
```
|
|
1570
|
+
|
|
1571
|
+
#### 5.2.8 Automation::MacroEngine — 登录宏引擎
|
|
1572
|
+
|
|
1573
|
+
```ruby
|
|
1574
|
+
# frozen_string_literal: true
|
|
1575
|
+
|
|
1576
|
+
module NetworkInfraUtility
|
|
1577
|
+
module SSH
|
|
1578
|
+
module Automation
|
|
1579
|
+
# 登录宏引擎,连接后自动执行预设命令序列。
|
|
1580
|
+
# 对应需求 FR-AUTO-001。
|
|
1581
|
+
class MacroEngine
|
|
1582
|
+
# 单步定义
|
|
1583
|
+
Step = Struct.new(:action, :wait_pattern, :delay, :on_fail, keyword_init: true)
|
|
1584
|
+
|
|
1585
|
+
def initialize(session)
|
|
1586
|
+
@session = session
|
|
1587
|
+
@steps = []
|
|
1588
|
+
@running = false
|
|
1589
|
+
@abort = false
|
|
1590
|
+
end
|
|
1591
|
+
|
|
1592
|
+
# 添加一步。
|
|
1593
|
+
# @param action [String] 要发送的命令
|
|
1594
|
+
# @param wait_pattern [String, Regexp] 等待匹配的文本/正则
|
|
1595
|
+
# @param delay [Float] 发送前延迟(秒)
|
|
1596
|
+
# @param on_fail [:continue, :abort, :ask] 失败时行为
|
|
1597
|
+
def add_step(action:, wait_pattern: nil, delay: 0, on_fail: :continue)
|
|
1598
|
+
raise "Too many steps (max 50)" if @steps.size >= 50
|
|
1599
|
+
@steps << Step.new(action: action, wait_pattern: wait_pattern,
|
|
1600
|
+
delay: delay, on_fail: on_fail)
|
|
1601
|
+
end
|
|
1602
|
+
|
|
1603
|
+
# 执行宏。
|
|
1604
|
+
def run(on_progress: nil)
|
|
1605
|
+
@running = true
|
|
1606
|
+
@steps.each_with_index do |step, i|
|
|
1607
|
+
break if @abort
|
|
1608
|
+
on_progress&.call(step, i + 1, @steps.size)
|
|
1609
|
+
sleep step.delay if step.delay > 0
|
|
1610
|
+
@session.terminal.send(step.action)
|
|
1611
|
+
if step.wait_pattern
|
|
1612
|
+
result = wait_for(step.wait_pattern, timeout: 30)
|
|
1613
|
+
handle_step_result(step, result)
|
|
1614
|
+
end
|
|
1615
|
+
end
|
|
1616
|
+
ensure
|
|
1617
|
+
@running = false
|
|
1618
|
+
end
|
|
1619
|
+
|
|
1620
|
+
def abort
|
|
1621
|
+
@abort = true
|
|
1622
|
+
end
|
|
1623
|
+
|
|
1624
|
+
private
|
|
1625
|
+
|
|
1626
|
+
def wait_for(pattern, timeout:)
|
|
1627
|
+
regex = pattern.is_a?(Regexp) ? pattern : Regexp.new(Regexp.escape(pattern))
|
|
1628
|
+
deadline = Time.now + timeout
|
|
1629
|
+
accumulated = ""
|
|
1630
|
+
loop do
|
|
1631
|
+
return :matched if accumulated.match?(regex)
|
|
1632
|
+
return :timeout if Time.now > deadline
|
|
1633
|
+
line = @session.terminal.buffer.last_line&.to_s || ""
|
|
1634
|
+
accumulated << line + "\n"
|
|
1635
|
+
sleep 0.1
|
|
1636
|
+
end
|
|
1637
|
+
end
|
|
1638
|
+
|
|
1639
|
+
def handle_step_result(step, result)
|
|
1640
|
+
case [result, step.on_fail]
|
|
1641
|
+
when [:matched, any], [:timeout, :continue]
|
|
1642
|
+
# 继续
|
|
1643
|
+
when [:timeout, :abort]
|
|
1644
|
+
@abort = true
|
|
1645
|
+
when [:timeout, :ask]
|
|
1646
|
+
# 询问用户——CLI 版输出提示,TUI 版弹窗
|
|
1647
|
+
yield(:ask, step) if block_given?
|
|
1648
|
+
end
|
|
1649
|
+
end
|
|
1650
|
+
end
|
|
1651
|
+
end
|
|
1652
|
+
end
|
|
1653
|
+
end
|
|
1654
|
+
```
|
|
1655
|
+
|
|
1656
|
+
#### 5.2.9 Automation::BatchExec — 批量执行
|
|
1657
|
+
|
|
1658
|
+
```ruby
|
|
1659
|
+
# frozen_string_literal: true
|
|
1660
|
+
|
|
1661
|
+
module NetworkInfraUtility
|
|
1662
|
+
module SSH
|
|
1663
|
+
module Automation
|
|
1664
|
+
# 批量命令执行,多会话同时下发。
|
|
1665
|
+
# 对应需求 FR-AUTO-003。
|
|
1666
|
+
class BatchExec
|
|
1667
|
+
# 执行模式
|
|
1668
|
+
PARALLEL = :parallel
|
|
1669
|
+
SERIAL = :serial
|
|
1670
|
+
|
|
1671
|
+
def initialize(client)
|
|
1672
|
+
@client = client
|
|
1673
|
+
end
|
|
1674
|
+
|
|
1675
|
+
# 批量执行命令。
|
|
1676
|
+
# @param sessions [Array<Session>] 目标会话列表
|
|
1677
|
+
# @param command [String] 要执行的命令
|
|
1678
|
+
# @param mode [:parallel, :serial] 执行模式
|
|
1679
|
+
# @param timeout [Integer] 单会话超时秒数
|
|
1680
|
+
# @return [Hash<conn_id => Result>]
|
|
1681
|
+
def execute(sessions, command, mode: PARALLEL, timeout: 30)
|
|
1682
|
+
if mode == PARALLEL
|
|
1683
|
+
execute_parallel(sessions, command, timeout)
|
|
1684
|
+
else
|
|
1685
|
+
execute_serial(sessions, command, timeout)
|
|
1686
|
+
end
|
|
1687
|
+
end
|
|
1688
|
+
|
|
1689
|
+
private
|
|
1690
|
+
|
|
1691
|
+
def execute_parallel(sessions, command, timeout)
|
|
1692
|
+
threads = sessions.map do |s|
|
|
1693
|
+
Thread.new { [s.conn_id, run_on_session(s, command, timeout)] }
|
|
1694
|
+
end
|
|
1695
|
+
results = threads.map(&:value)
|
|
1696
|
+
results.to_h
|
|
1697
|
+
end
|
|
1698
|
+
|
|
1699
|
+
def execute_serial(sessions, command, timeout)
|
|
1700
|
+
sessions.map do |s|
|
|
1701
|
+
[s.conn_id, run_on_session(s, command, timeout)]
|
|
1702
|
+
end.to_h
|
|
1703
|
+
end
|
|
1704
|
+
|
|
1705
|
+
def run_on_session(session, command, timeout)
|
|
1706
|
+
chan_id = @client.ipc.call("channel.open", {
|
|
1707
|
+
conn_id: session.conn_id, type: "exec", command: command
|
|
1708
|
+
})
|
|
1709
|
+
collector = ResultCollector.new
|
|
1710
|
+
@client.ipc.on_event("channel.data") do |params|
|
|
1711
|
+
next unless params[:id] == chan_id
|
|
1712
|
+
collector << Base64.decode64(params[:data])
|
|
1713
|
+
end
|
|
1714
|
+
collector.wait(timeout)
|
|
1715
|
+
end
|
|
1716
|
+
end
|
|
1717
|
+
|
|
1718
|
+
# 结果收集器,收集单个会话的命令输出。
|
|
1719
|
+
ResultCollector = Struct.new(:output, :done) do
|
|
1720
|
+
def initialize
|
|
1721
|
+
self.output = +""
|
|
1722
|
+
self.done = false
|
|
1723
|
+
@mutex = Mutex.new
|
|
1724
|
+
@cv = ConditionVariable.new
|
|
1725
|
+
end
|
|
1726
|
+
|
|
1727
|
+
def <<(data)
|
|
1728
|
+
@mutex.synchronize { output << data }
|
|
1729
|
+
end
|
|
1730
|
+
|
|
1731
|
+
def wait(timeout)
|
|
1732
|
+
deadline = Time.now + timeout
|
|
1733
|
+
@mutex.synchronize do
|
|
1734
|
+
@cv.wait(@mutex, [deadline - Time.now, 0].max) until done || Time.now >= deadline
|
|
1735
|
+
end
|
|
1736
|
+
output.to_s
|
|
1737
|
+
end
|
|
1738
|
+
|
|
1739
|
+
def finish!
|
|
1740
|
+
@mutex.synchronize do
|
|
1741
|
+
self.done = true
|
|
1742
|
+
@cv.broadcast
|
|
1743
|
+
end
|
|
1744
|
+
end
|
|
1745
|
+
end
|
|
1746
|
+
end
|
|
1747
|
+
end
|
|
1748
|
+
end
|
|
1749
|
+
```
|
|
1750
|
+
|
|
1751
|
+
### 5.3 CLI 入口设计
|
|
1752
|
+
|
|
1753
|
+
V1.0 先交付交互式 CLI,后续升级为 TUI 多面板。
|
|
1754
|
+
|
|
1755
|
+
```ruby
|
|
1756
|
+
#!/usr/bin/env ruby
|
|
1757
|
+
# frozen_string_literal: true
|
|
1758
|
+
|
|
1759
|
+
require "thor"
|
|
1760
|
+
require_relative "../lib/network_infra_utility/ssh/client"
|
|
1761
|
+
|
|
1762
|
+
module NetworkInfraUtility
|
|
1763
|
+
module SSH
|
|
1764
|
+
class CLI < Thor
|
|
1765
|
+
desc "connect HOST", "连接到 SSH 服务器"
|
|
1766
|
+
option :user, aliases: "-u", required: true
|
|
1767
|
+
option :port, aliases: "-p", type: :numeric, default: 22
|
|
1768
|
+
option :key, aliases: "-i", desc: "私钥文件路径"
|
|
1769
|
+
option :jump, aliases: "-J", desc: "跳板机 (user@host:port)"
|
|
1770
|
+
option :name, desc: "会话名称"
|
|
1771
|
+
|
|
1772
|
+
def connect(host)
|
|
1773
|
+
client = Client.new
|
|
1774
|
+
client.start_engine
|
|
1775
|
+
|
|
1776
|
+
spec = {
|
|
1777
|
+
host: host, port: options[:port], user: options[:user],
|
|
1778
|
+
name: options[:name]
|
|
1779
|
+
}
|
|
1780
|
+
spec[:key_path] = options[:key] if options[:key]
|
|
1781
|
+
spec[:jumps] = parse_jumps(options[:jump]) if options[:jump]
|
|
1782
|
+
|
|
1783
|
+
session = client.connect(spec)
|
|
1784
|
+
terminal = session.open_terminal
|
|
1785
|
+
|
|
1786
|
+
# 进入交互模式
|
|
1787
|
+
start_interactive(session, terminal)
|
|
1788
|
+
rescue => e
|
|
1789
|
+
STDERR.puts "Error: #{e.message}"
|
|
1790
|
+
exit 1
|
|
1791
|
+
ensure
|
|
1792
|
+
client&.stop
|
|
1793
|
+
end
|
|
1794
|
+
|
|
1795
|
+
desc "batch FILE", "批量在多个会话上执行命令"
|
|
1796
|
+
option :command, aliases: "-c", required: true
|
|
1797
|
+
option :serial, type: :boolean, default: false
|
|
1798
|
+
|
|
1799
|
+
def batch(file)
|
|
1800
|
+
# 从文件读取会话列表,批量执行
|
|
1801
|
+
sessions = load_sessions_from_file(file)
|
|
1802
|
+
client = Client.new
|
|
1803
|
+
client.start_engine
|
|
1804
|
+
sessions.map! { |spec| client.connect(spec).open_terminal }
|
|
1805
|
+
|
|
1806
|
+
batch = Automation::BatchExec.new(client)
|
|
1807
|
+
results = batch.execute(sessions, options[:command],
|
|
1808
|
+
mode: options[:serial] ? :serial : :parallel)
|
|
1809
|
+
print_results(results)
|
|
1810
|
+
end
|
|
1811
|
+
|
|
1812
|
+
private
|
|
1813
|
+
|
|
1814
|
+
def start_interactive(session, terminal)
|
|
1815
|
+
# V1.0: 简单的读-发-收循环
|
|
1816
|
+
# V2.0: 升级为 curses 多面板 TUI
|
|
1817
|
+
loop do
|
|
1818
|
+
line = STDIN.gets
|
|
1819
|
+
break if line.nil? || line.chomp == "exit"
|
|
1820
|
+
terminal.puts(line.chomp)
|
|
1821
|
+
end
|
|
1822
|
+
end
|
|
1823
|
+
|
|
1824
|
+
def parse_jumps(jump_str)
|
|
1825
|
+
jump_str.split(",").map do |j|
|
|
1826
|
+
if j =~ /\A([^@]+)@([^:]+):(\d+)\z/
|
|
1827
|
+
{user: $1, host: $2, port: $3.to_i}
|
|
1828
|
+
else
|
|
1829
|
+
raise "Invalid jump format: #{j} (expected user@host:port)"
|
|
1830
|
+
end
|
|
1831
|
+
end
|
|
1832
|
+
end
|
|
1833
|
+
end
|
|
1834
|
+
end
|
|
1835
|
+
end
|
|
1836
|
+
|
|
1837
|
+
NetworkInfraUtility::SSH::CLI.start(ARGV)
|
|
1838
|
+
```
|
|
1839
|
+
|
|
1840
|
+
---
|
|
1841
|
+
|
|
1842
|
+
## 6 通信协议设计
|
|
1843
|
+
|
|
1844
|
+
### 6.1 JSON-RPC 2.0 消息格式
|
|
1845
|
+
|
|
1846
|
+
Erlang 与 Ruby 之间所有通信均使用 JSON-RPC 2.0,每条消息以换行符 `\n` 分隔。
|
|
1847
|
+
|
|
1848
|
+
#### 6.1.1 请求
|
|
1849
|
+
|
|
1850
|
+
```json
|
|
1851
|
+
{"jsonrpc":"2.0","id":1,"method":"conn.connect","params":{"host":"10.0.0.1","port":22,"user":"admin","auth":{"type":"password","value":"~vault:sec_001"}}}
|
|
1852
|
+
```
|
|
1853
|
+
|
|
1854
|
+
#### 6.1.2 响应
|
|
1855
|
+
|
|
1856
|
+
```json
|
|
1857
|
+
{"jsonrpc":"2.0","id":1,"result":{"conn_id":"conn_1691000000_a1b2c3d4","fingerprint":"SHA256:AbC..."}}
|
|
1858
|
+
```
|
|
1859
|
+
|
|
1860
|
+
#### 6.1.3 错误响应
|
|
1861
|
+
|
|
1862
|
+
```json
|
|
1863
|
+
{"jsonrpc":"2.0","id":1,"error":{"code":-32001,"message":"Authentication failed","data":{"reason":"publickey rejected"}}}
|
|
1864
|
+
```
|
|
1865
|
+
|
|
1866
|
+
#### 6.1.4 异步推送(无 id)
|
|
1867
|
+
|
|
1868
|
+
```json
|
|
1869
|
+
{"jsonrpc":"2.0","method":"channel.data","params":{"id":"ch_001","data":"dGVzdA=="}}
|
|
1870
|
+
```
|
|
1871
|
+
|
|
1872
|
+
### 6.2 错误码定义
|
|
1873
|
+
|
|
1874
|
+
| 错误码 | 含义 | 说明 |
|
|
1875
|
+
|--------|------|------|
|
|
1876
|
+
| -32700 | Parse error | JSON 解析失败 |
|
|
1877
|
+
| -32600 | Invalid Request | 请求格式不合法 |
|
|
1878
|
+
| -32601 | Method not found | 方法未注册 |
|
|
1879
|
+
| -32602 | Invalid params | 参数校验失败 |
|
|
1880
|
+
| -32603 | Internal error | Erlang 内部异常 |
|
|
1881
|
+
| -32001 | Connection error | SSH 连接失败 |
|
|
1882
|
+
| -32002 | Authentication error | 认证失败 |
|
|
1883
|
+
| -32003 | Channel error | 通道操作失败 |
|
|
1884
|
+
| -32004 | Host key error | 主机密钥校验失败 |
|
|
1885
|
+
| -32005 | Timeout | 操作超时 |
|
|
1886
|
+
| -32006 | SFTP error | SFTP 操作失败 |
|
|
1887
|
+
| -32007 | Port forward error | 端口转发失败 |
|
|
1888
|
+
|
|
1889
|
+
### 6.3 RPC 方法清单
|
|
1890
|
+
|
|
1891
|
+
#### 6.3.1 连接管理
|
|
1892
|
+
|
|
1893
|
+
| 方法 | 方向 | 参数 | 返回 | 对应需求 |
|
|
1894
|
+
|------|------|------|------|----------|
|
|
1895
|
+
| `conn.connect` | Ruby→Erlang | `{host, port, user, auth, jumps?, proxy?, keepalive?}` | `{conn_id, fingerprint}` | FR-CONN-001/002/003/004 |
|
|
1896
|
+
| `conn.disconnect` | Ruby→Erlang | `{id}` | `{ok}` | — |
|
|
1897
|
+
| `conn.list` | Ruby→Erlang | `{}` | `[{conn_id, host, state}]` | — |
|
|
1898
|
+
| `conn.reconnect` | Ruby→Erlang | `{id}` | `{conn_id}` | FR-CONN-005 |
|
|
1899
|
+
| `conn.ready` | Erlang→Ruby | `{conn_id}` | — | 连接就绪通知 |
|
|
1900
|
+
| `conn.closed` | Erlang→Ruby | `{conn_id, reason}` | — | 连接断开通知 |
|
|
1901
|
+
|
|
1902
|
+
#### 6.3.2 通道管理
|
|
1903
|
+
|
|
1904
|
+
| 方法 | 方向 | 参数 | 返回 | 对应需求 |
|
|
1905
|
+
|------|------|------|------|----------|
|
|
1906
|
+
| `channel.open` | Ruby→Erlang | `{conn_id, type, term?, cols?, rows?, command?}` | `{channel_id}` | — |
|
|
1907
|
+
| `channel.send` | Ruby→Erlang | `{id, data}` | `{ok}` | — |
|
|
1908
|
+
| `channel.close` | Ruby→Erlang | `{id}` | `{ok}` | — |
|
|
1909
|
+
| `channel.window_change` | Ruby→Erlang | `{id, cols, rows}` | `{ok}` | FR-TERM-001 |
|
|
1910
|
+
| `channel.data` | Erlang→Ruby | `{id, data}` | — | 终端数据推送 |
|
|
1911
|
+
| `channel.eof` | Erlang→Ruby | `{id, reason}` | — | 通道关闭通知 |
|
|
1912
|
+
|
|
1913
|
+
#### 6.3.3 认证
|
|
1914
|
+
|
|
1915
|
+
| 方法 | 方向 | 参数 | 返回 | 对应需求 |
|
|
1916
|
+
|------|------|------|------|----------|
|
|
1917
|
+
| `auth.prompt` | Erlang→Ruby | `{conn_id, prompts[]}` | `{responses[]}` | FR-CONN-002 键盘交互 |
|
|
1918
|
+
| `hostkey.verify` | Erlang→Ruby | `{conn_id, host, port, fingerprint, key_type}` | `{accept/once/reject}` | FR-SEC-002 |
|
|
1919
|
+
|
|
1920
|
+
#### 6.3.4 端口转发
|
|
1921
|
+
|
|
1922
|
+
| 方法 | 方向 | 参数 | 返回 | 对应需求 |
|
|
1923
|
+
|------|------|------|------|----------|
|
|
1924
|
+
| `portfwd.add` | Ruby→Erlang | `{conn_id, type, local_port, remote_host, remote_port}` | `{rule_id}` | FR-NET-001 |
|
|
1925
|
+
| `portfwd.remove` | Ruby→Erlang | `{conn_id, rule_id}` | `{ok}` | FR-NET-001 |
|
|
1926
|
+
| `portfwd.list` | Ruby→Erlang | `{conn_id}` | `[{rule_id, type, ...}]` | FR-NET-001 |
|
|
1927
|
+
|
|
1928
|
+
#### 6.3.5 SFTP
|
|
1929
|
+
|
|
1930
|
+
| 方法 | 方向 | 参数 | 返回 | 对应需求 |
|
|
1931
|
+
|------|------|------|------|----------|
|
|
1932
|
+
| `sftp.open` | Ruby→Erlang | `{conn_id}` | `{sftp_id}` | FR-FILE-001 |
|
|
1933
|
+
| `sftp.list_dir` | Ruby→Erlang | `{sftp_id, path}` | `{entries[{name, type, size, mtime, perms}]}` | FR-FILE-001 |
|
|
1934
|
+
| `sftp.download` | Ruby→Erlang | `{sftp_id, remote, local}` | `{ok, transferred}` | FR-FILE-001 |
|
|
1935
|
+
| `sftp.upload` | Ruby→Erlang | `{sftp_id, local, remote}` | `{ok, transferred}` | FR-FILE-001 |
|
|
1936
|
+
| `sftp.mkdir` | Ruby→Erlang | `{sftp_id, path}` | `{ok}` | FR-FILE-001 |
|
|
1937
|
+
| `sftp.remove` | Ruby→Erlang | `{sftp_id, path}` | `{ok}` | FR-FILE-001 |
|
|
1938
|
+
| `sftp.stat` | Ruby→Erlang | `{sftp_id, path}` | `{size, mtime, perms, type}` | FR-FILE-001 |
|
|
1939
|
+
| `sftp.progress` | Erlang→Ruby | `{sftp_id, transferred, total, speed}` | — | 传输进度推送 |
|
|
1940
|
+
|
|
1941
|
+
#### 6.3.6 管理与诊断
|
|
1942
|
+
|
|
1943
|
+
| 方法 | 方向 | 参数 | 返回 | 对应需求 |
|
|
1944
|
+
|------|------|------|------|----------|
|
|
1945
|
+
| `engine.ping` | Ruby→Erlang | `{}` | `{ok, timestamp}` | 心跳检测 |
|
|
1946
|
+
| `engine.stats` | Ruby→Erlang | `{}` | `{connections, channels, uptime, memory}` | NFR-PERF-005/006 |
|
|
1947
|
+
| `engine.shutdown` | Ruby→Erlang | `{}` | `{ok}` | 优雅关闭 |
|
|
1948
|
+
|
|
1949
|
+
### 6.4 数据编码约定
|
|
1950
|
+
|
|
1951
|
+
| 数据类型 | 编码方式 | 说明 |
|
|
1952
|
+
|----------|----------|------|
|
|
1953
|
+
| 终端原始字节 | Base64 | 二进制安全,跨语言无坑 |
|
|
1954
|
+
| 文件路径 | UTF-8 字符串 | 直接传递 |
|
|
1955
|
+
| 时间戳 | Unix 毫秒整数 | 避免时区问题 |
|
|
1956
|
+
| 密钥指纹 | 字符串 `SHA256:base64` | 与 OpenSSH 一致 |
|
|
1957
|
+
| 认证凭据 | `~vault:<key>` 引用 | Ruby 解析后替换为明文,仅在 IPC 中短暂传输 |
|
|
1958
|
+
|
|
1959
|
+
### 6.5 IPC 性能保障
|
|
1960
|
+
|
|
1961
|
+
| 指标 | 目标 | 措施 |
|
|
1962
|
+
|------|------|------|
|
|
1963
|
+
| 单次 RPC 往返延迟 | ≤ 1ms(Unix Socket) | 本地 IPC,无网络开销 |
|
|
1964
|
+
| 终端数据推送延迟 | ≤ 5ms | base64 编解码 + JSON 序列化约 0.2ms |
|
|
1965
|
+
| 消息吞吐 | ≥ 10000 msg/s | Erlang 端无需序列化整行,直接 `jsx:encode` |
|
|
1966
|
+
| 大数据流传输 | 不阻塞小消息 | SFTP 数据使用独立 channel,不与终端复用 |
|
|
1967
|
+
|
|
1968
|
+
---
|
|
1969
|
+
|
|
1970
|
+
## 7 数据模型设计
|
|
1971
|
+
|
|
1972
|
+
### 7.1 会话配置模型
|
|
1973
|
+
|
|
1974
|
+
```yaml
|
|
1975
|
+
# ~/.network-infra-utility/sessions.yml
|
|
1976
|
+
version: 1
|
|
1977
|
+
groups:
|
|
1978
|
+
- id: grp_datacenter_a
|
|
1979
|
+
name: "A机房"
|
|
1980
|
+
parent: null
|
|
1981
|
+
groups:
|
|
1982
|
+
- id: grp_datacenter_a_core
|
|
1983
|
+
name: "核心交换"
|
|
1984
|
+
parent: grp_datacenter_a
|
|
1985
|
+
sessions:
|
|
1986
|
+
- id: sess_001
|
|
1987
|
+
name: "核心交换机-01"
|
|
1988
|
+
group: grp_datacenter_a_core
|
|
1989
|
+
host: "10.0.0.1"
|
|
1990
|
+
port: 22
|
|
1991
|
+
user: "admin"
|
|
1992
|
+
auth:
|
|
1993
|
+
type: publickey
|
|
1994
|
+
key_path: "~/.ssh/id_ed25519"
|
|
1995
|
+
passphrase_ref: "~vault:sess_001_key_pass"
|
|
1996
|
+
tags: ["core", "cisco"]
|
|
1997
|
+
terminal:
|
|
1998
|
+
type: "xterm-256color"
|
|
1999
|
+
theme: "solarized-dark"
|
|
2000
|
+
font: "JetBrains Mono"
|
|
2001
|
+
font_size: 14
|
|
2002
|
+
scrollback: 10000
|
|
2003
|
+
encoding: "utf-8"
|
|
2004
|
+
keepalive:
|
|
2005
|
+
interval: 30
|
|
2006
|
+
count_max: 3
|
|
2007
|
+
proxy:
|
|
2008
|
+
type: "socks5"
|
|
2009
|
+
host: "127.0.0.1"
|
|
2010
|
+
port: 1080
|
|
2011
|
+
jumps:
|
|
2012
|
+
- host: "172.16.0.1"
|
|
2013
|
+
port: 22
|
|
2014
|
+
user: "jump"
|
|
2015
|
+
auth:
|
|
2016
|
+
type: password
|
|
2017
|
+
password_ref: "~vault:jump_001_pass"
|
|
2018
|
+
port_forwards:
|
|
2019
|
+
- type: local
|
|
2020
|
+
local_port: 8080
|
|
2021
|
+
remote_host: "127.0.0.1"
|
|
2022
|
+
remote_port: 80
|
|
2023
|
+
enabled: true
|
|
2024
|
+
macro:
|
|
2025
|
+
enabled: true
|
|
2026
|
+
steps:
|
|
2027
|
+
- action: "terminal length 0"
|
|
2028
|
+
wait_pattern: "#"
|
|
2029
|
+
delay: 1
|
|
2030
|
+
- action: "show version"
|
|
2031
|
+
wait_pattern: "#"
|
|
2032
|
+
delay: 0
|
|
2033
|
+
log:
|
|
2034
|
+
enabled: true
|
|
2035
|
+
path: "~/ssh-logs/sess_001"
|
|
2036
|
+
max_size: 100MB
|
|
2037
|
+
rotate: 10
|
|
2038
|
+
```
|
|
2039
|
+
|
|
2040
|
+
### 7.2 Erlang 内部状态模型
|
|
2041
|
+
|
|
2042
|
+
```erlang
|
|
2043
|
+
%% 连接规格映射
|
|
2044
|
+
-type connect_spec() :: #{
|
|
2045
|
+
host => binary(),
|
|
2046
|
+
port => pos_integer(),
|
|
2047
|
+
user => binary(),
|
|
2048
|
+
auth => #{
|
|
2049
|
+
type => password | publickey | keyboard_interactive,
|
|
2050
|
+
password => binary() | undefined,
|
|
2051
|
+
key_path => binary() | undefined,
|
|
2052
|
+
passphrase => binary() | undefined,
|
|
2053
|
+
chain => [atom()] %% 认证回退链
|
|
2054
|
+
},
|
|
2055
|
+
jumps => [connect_spec()] | undefined,
|
|
2056
|
+
proxy => #{
|
|
2057
|
+
type => http | socks5,
|
|
2058
|
+
host => binary(),
|
|
2059
|
+
port => pos_integer(),
|
|
2060
|
+
auth => map() | undefined
|
|
2061
|
+
} | undefined,
|
|
2062
|
+
keepalive => #{
|
|
2063
|
+
interval => pos_integer(),
|
|
2064
|
+
count_max => pos_integer()
|
|
2065
|
+
},
|
|
2066
|
+
algorithms => map() | undefined %% 可选自定义算法列表
|
|
2067
|
+
}.
|
|
2068
|
+
```
|
|
2069
|
+
|
|
2070
|
+
### 7.3 配置文件布局
|
|
2071
|
+
|
|
2072
|
+
```
|
|
2073
|
+
~/.network-infra-utility/
|
|
2074
|
+
├── settings.yml # 全局设置(主题、快捷键、默认终端参数)
|
|
2075
|
+
├── sessions.yml # 会话与分组配置
|
|
2076
|
+
├── vault.yml # 加密凭据存储(权限 600)
|
|
2077
|
+
├── known_hosts.yml # 已知主机密钥
|
|
2078
|
+
├── snippets/ # 代码片段库
|
|
2079
|
+
│ ├── network.yml
|
|
2080
|
+
│ └── linux.yml
|
|
2081
|
+
├── themes/ # 自定义主题
|
|
2082
|
+
│ └── custom.yml
|
|
2083
|
+
├── macros/ # 宏脚本
|
|
2084
|
+
│ └── cisco_login.yml
|
|
2085
|
+
└── logs/ # 会话日志
|
|
2086
|
+
├── sess_001/
|
|
2087
|
+
│ ├── 20260802_153000.log
|
|
2088
|
+
│ └── 20260802_160000.log
|
|
2089
|
+
└── ...
|
|
2090
|
+
```
|
|
2091
|
+
|
|
2092
|
+
### 7.4 关键数据结构对比
|
|
2093
|
+
|
|
2094
|
+
| 数据 | Ruby 侧表示 | Erlang 侧表示 | 持久化位置 |
|
|
2095
|
+
|------|------------|---------------|-----------|
|
|
2096
|
+
| 会话配置 | `Session` 对象 | 不持久化(每次 connect 传入) | `sessions.yml` |
|
|
2097
|
+
| 凭据 | Vault 引用 `~vault:key` | 运行时明文(仅 IPC 传输) | `vault.yml`(加密) |
|
|
2098
|
+
| 已知主机 | `Hash<host, key>` | ETS 表 | `known_hosts.yml` |
|
|
2099
|
+
| 代码片段 | `Array<Snippet>` | 不管理 | `snippets/*.yml` |
|
|
2100
|
+
| 会话日志 | 文件句柄 | 不持久化 | `logs/` 目录 |
|
|
2101
|
+
| 终端缓冲 | `Buffer` 对象 | 不持久化 | 内存中 |
|
|
2102
|
+
|
|
2103
|
+
---
|
|
2104
|
+
|
|
2105
|
+
## 8 安全设计
|
|
2106
|
+
|
|
2107
|
+
### 8.1 安全架构总览
|
|
2108
|
+
|
|
2109
|
+
```
|
|
2110
|
+
┌─ 用户 ────────────────────────────────────────┐
|
|
2111
|
+
│ 主密码(不存储,仅内存) │
|
|
2112
|
+
└──────┬────────────────────────────────────────┘
|
|
2113
|
+
│ PBKDF2/Argon2 派生
|
|
2114
|
+
▼
|
|
2115
|
+
┌─ Ruby Vault 层 ───────────────────────────────┐
|
|
2116
|
+
│ AES-256-GCM 加密存储 vault.yml │
|
|
2117
|
+
│ 凭据仅在 connect 时解密,经 IPC 传入 Erlang │
|
|
2118
|
+
└──────┬────────────────────────────────────────┘
|
|
2119
|
+
│ JSON-RPC (本地 IPC, 不经网络)
|
|
2120
|
+
▼
|
|
2121
|
+
┌─ Erlang 引擎层 ───────────────────────────────┐
|
|
2122
|
+
│ 认证凭据仅在 ssh:connect 调用期间存活 │
|
|
2123
|
+
│ 连接建立后凭据从内存清除 │
|
|
2124
|
+
│ SSH 加密由 OTP ssh 应用保证 │
|
|
2125
|
+
└──────┬────────────────────────────────────────┘
|
|
2126
|
+
│ SSH 加密通道
|
|
2127
|
+
▼
|
|
2128
|
+
┌─ 远程 SSH 服务器 ─────────────────────────────┐
|
|
2129
|
+
└────────────────────────────────────────────────┘
|
|
2130
|
+
```
|
|
2131
|
+
|
|
2132
|
+
### 8.2 凭据安全 — FR-SEC-001
|
|
2133
|
+
|
|
2134
|
+
| 环节 | 措施 | 验收标准 |
|
|
2135
|
+
|------|------|----------|
|
|
2136
|
+
| 主密码存储 | PBKDF2-SHA256 迭代 ≥ 100000 次,仅存哈希 | NFR-SEC-002 |
|
|
2137
|
+
| 凭据存储 | AES-256-GCM 加密,随机 salt + nonce + auth_tag | FR-SEC-001 ①② |
|
|
2138
|
+
| 配置文件 | vault.yml 权限 600(Unix)/ ACL 限制(Windows) | FR-SEC-001 ④ |
|
|
2139
|
+
| 内存安全 | 凭据解密后仅存在连接建立期间,连接成功后 GC 清除 | NFR-SEC-001 |
|
|
2140
|
+
| 日志安全 | 日志中不记录明文凭据,配置中用 `~vault:` 引用替代替 | NFR-SEC-001 |
|
|
2141
|
+
| IPC 传输 | Unix Socket 本地通信,不经网络;Windows TCP 仅绑定 127.0.0.1 | — |
|
|
2142
|
+
|
|
2143
|
+
### 8.3 主机密钥验证 — FR-SEC-002
|
|
2144
|
+
|
|
2145
|
+
```erlang
|
|
2146
|
+
%% 首次连接流程
|
|
2147
|
+
1. Erlang 连接时获取服务器公钥指纹
|
|
2148
|
+
2. 调用 hostkey.verify 推送到 Ruby
|
|
2149
|
+
3. Ruby 查询 known_hosts:
|
|
2150
|
+
a. 已知且匹配 → 自动接受
|
|
2151
|
+
b. 已知但不匹配 → 弹窗告警 "主机密钥变更!可能存在中间人攻击"
|
|
2152
|
+
c. 未知 → 弹窗确认 "首次连接,指纹 SHA256:xxx,是否信任?"
|
|
2153
|
+
4. 用户确认后 Ruby 回复 accept/reject
|
|
2154
|
+
5. accept 后 Ruby 将指纹写入 known_hosts.yml
|
|
2155
|
+
```
|
|
2156
|
+
|
|
2157
|
+
### 8.4 IPC 认证
|
|
2158
|
+
|
|
2159
|
+
| 步骤 | 机制 |
|
|
2160
|
+
|------|------|
|
|
2161
|
+
| 1. Erlang 引擎启动 | 生成随机 32 字节令牌,与监听端点一起写入临时文件 |
|
|
2162
|
+
| 2. 文件权限 | 临时文件权限设为 600,路径包含 UID 防止其他用户读取 |
|
|
2163
|
+
| 3. Ruby 读取 | 读取端点和令牌 |
|
|
2164
|
+
| 4. 首次 RPC | Ruby 在 params 中携带 `auth_token` |
|
|
2165
|
+
| 5. Erlang 校验 | 匹配后绑定 TCP 连接,后续请求无需再传 |
|
|
2166
|
+
| 6. 连接关闭 | 令牌失效,防止重放 |
|
|
2167
|
+
|
|
2168
|
+
### 8.5 依赖安全 — NFR-SEC-003
|
|
2169
|
+
|
|
2170
|
+
| 依赖 | 来源 | 安全策略 |
|
|
2171
|
+
|------|------|----------|
|
|
2172
|
+
| Erlang/OTP ssh | OTP 内置 | 跟随 OTP 安全更新 |
|
|
2173
|
+
| jsx | hex.pm | 发布前 `rebar3 audit` 检查 CVE |
|
|
2174
|
+
| Ruby stdlib | 内置 | 跟随 Ruby 安全更新 |
|
|
2175
|
+
| thor | RubyGems | `bundle audit` 检查 |
|
|
2176
|
+
|
|
2177
|
+
---
|
|
2178
|
+
|
|
2179
|
+
## 9 需求映射矩阵
|
|
2180
|
+
|
|
2181
|
+
### 9.1 功能需求映射
|
|
2182
|
+
|
|
2183
|
+
下表将每条功能需求映射到实现层和核心模块:
|
|
2184
|
+
|
|
2185
|
+
| 需求编号 | 功能名称 | Erlang 模块 | Ruby 模块 | 优先级 | 版本 |
|
|
2186
|
+
|----------|----------|-------------|-----------|--------|------|
|
|
2187
|
+
| FR-CONN-001 | SSH2 协议支持 | ssh_conn_worker + OTP ssh | IPC::Client | P0 | V1.0 |
|
|
2188
|
+
| FR-CONN-002 | 多认证方式 | ssh_auth_engine | Security::Vault | P0 | V1.0 |
|
|
2189
|
+
| FR-CONN-003 | 跳板机跳转 | ssh_jump_chain | Session::Manager | P0 | V1.0 |
|
|
2190
|
+
| FR-CONN-004 | 连接保活 | ssh_keepalive_mgr | Config::Settings | P0 | V1.0 |
|
|
2191
|
+
| FR-CONN-005 | 自动重连 | ssh_conn_worker | Session::Manager | P1 | V2.0 |
|
|
2192
|
+
| FR-CONN-006 | 多协议支持 | 扩展 conn_worker | Session::Manager | P1 | V2.0 |
|
|
2193
|
+
| FR-CONN-007 | MOSH 支持 | 新 mosh_engine | — | P2 | V3.0 |
|
|
2194
|
+
| FR-SESS-001 | 标签页与窗口拆分 | — | Session::Manager + TUI | P0 | V1.0 |
|
|
2195
|
+
| FR-SESS-002 | 会话分组与树形管理 | — | Session::Tree | P0 | V1.0 |
|
|
2196
|
+
| FR-SESS-003 | 会话导入导出 | — | Config::ImportExport | P1 | V2.0 |
|
|
2197
|
+
| FR-SESS-004 | 会话搜索 | — | Session::Manager#search | P1 | V2.0 |
|
|
2198
|
+
| FR-SESS-005 | 最近连接列表 | — | Session::History | P1 | V2.0 |
|
|
2199
|
+
| FR-SESS-006 | 会话锁屏 | — | TUI 层 | P2 | V3.0 |
|
|
2200
|
+
| FR-FILE-001 | SFTP 可视化浏览器 | ssh_sftp_engine | File::SftpBrowser | P0 | V1.0 |
|
|
2201
|
+
| FR-FILE-002 | SCP 命令传输 | ssh_sftp_engine | File::Transfer | P0 | V1.0 |
|
|
2202
|
+
| FR-FILE-003 | 双面板同步浏览 | ssh_channel_fsm | File::SftpBrowser | P1 | V2.0 |
|
|
2203
|
+
| FR-FILE-004 | ZMODEM | ssh_channel_fsm | File::Zmodem | P1 | V2.0 |
|
|
2204
|
+
| FR-FILE-005 | 批量文件传输 | ssh_sftp_engine | File::Transfer | P1 | V2.0 |
|
|
2205
|
+
| FR-FILE-006 | 远程文件编辑 | — | File::Watcher | P2 | V3.0 |
|
|
2206
|
+
| FR-SEC-001 | 凭据加密存储 | — | Security::Vault | P0 | V1.0 |
|
|
2207
|
+
| FR-SEC-002 | 主机密钥验证 | ssh_known_hosts | Security::HostKey | P0 | V1.0 |
|
|
2208
|
+
| FR-SEC-003 | SSH Agent 集成 | ssh_auth_engine | Security::KeyManager | P1 | V2.0 |
|
|
2209
|
+
| FR-SEC-004 | 密钥对生成 | — | Security::KeyManager | P1 | V2.0 |
|
|
2210
|
+
| FR-SEC-005 | 凭据同步加密 | — | Security::Vault | P2 | V3.0 |
|
|
2211
|
+
| FR-SEC-006 | 2FA/MFA 支持 | ssh_auth_engine | Security::OTP | P2 | V3.0 |
|
|
2212
|
+
| FR-NET-001 | 端口转发 | ssh_port_fwd | Network::PortForward | P0 | V1.0 |
|
|
2213
|
+
| FR-NET-002 | SOCKS5/HTTP 代理 | ssh_conn_worker | Network::Proxy | P1 | V2.0 |
|
|
2214
|
+
| FR-NET-003 | X11 转发 | ssh_port_fwd | — | P2 | V3.0 |
|
|
2215
|
+
| FR-NET-004 | 连接诊断 | — | Network::Diagnostics | P1 | V2.0 |
|
|
2216
|
+
| FR-NET-005 | 流量统计 | ssh_conn_worker | Network::Stats | P2 | V3.0 |
|
|
2217
|
+
| FR-TERM-001 | 终端仿真 | ssh_channel_fsm | Terminal::Emulator | P0 | V1.0 |
|
|
2218
|
+
| FR-TERM-002 | 主题与配色 | — | Terminal::Theme | P0 | V1.0 |
|
|
2219
|
+
| FR-TERM-003 | 字体配置 | — | Terminal::Renderer | P0 | V1.0 |
|
|
2220
|
+
| FR-TERM-004 | 快捷键定制 | — | TUI 层 | P1 | V2.0 |
|
|
2221
|
+
| FR-TERM-005 | 搜索与高亮 | — | Terminal::Buffer | P1 | V2.0 |
|
|
2222
|
+
| FR-TERM-006 | 日志记录 | ssh_channel_fsm | Terminal::Logger | P0 | V1.0 |
|
|
2223
|
+
| FR-TERM-007 | 回滚缓冲区 | — | Terminal::Buffer | P0 | V1.0 |
|
|
2224
|
+
| FR-TERM-008 | 输入同步/广播 | — | Session::Manager#broadcast | P1 | V2.0 |
|
|
2225
|
+
| FR-TERM-009 | 代码片段管理 | — | Automation::SnippetManager | P1 | V2.0 |
|
|
2226
|
+
| FR-TERM-010 | 智能选中 | — | Terminal::Emulator | P1 | V2.0 |
|
|
2227
|
+
| FR-TERM-011 | 窗口透明度 | — | GUI 层 | P2 | V3.0 |
|
|
2228
|
+
| FR-TERM-012 | 光标样式 | — | Terminal::Renderer | P2 | V3.0 |
|
|
2229
|
+
| FR-AUTO-001 | 登录宏/脚本 | — | Automation::MacroEngine | P0 | V1.0 |
|
|
2230
|
+
| FR-AUTO-002 | 会话录放 | — | Automation::Recorder | P2 | V3.0 |
|
|
2231
|
+
| FR-AUTO-003 | 命令批量执行 | ssh_channel_fsm | Automation::BatchExec | P1 | V2.0 |
|
|
2232
|
+
| FR-AUTO-004 | 本地 Shell 集成 | — | TUI 层 | P1 | V2.0 |
|
|
2233
|
+
| FR-AUTO-005 | 脚本引擎扩展 | — | Automation::ScriptEngine | P2 | V3.0 |
|
|
2234
|
+
| FR-COLLAB-001 | 跨平台支持 | Rebar3 跨平台编译 | Ruby 跨平台 | P1 | V2.0 |
|
|
2235
|
+
| FR-COLLAB-002 | 配置云同步 | — | Config::Sync | P2 | V3.0 |
|
|
2236
|
+
| FR-COLLAB-003 | 团队共享 | — | Config::TeamShare | P2 | V3.0 |
|
|
2237
|
+
| FR-COLLAB-004 | 便携版 | — | 打包脚本 | P2 | V3.0 |
|
|
2238
|
+
|
|
2239
|
+
### 9.2 非功能需求映射
|
|
2240
|
+
|
|
2241
|
+
| 编号 | 指标 | 实现措施 |
|
|
2242
|
+
|------|------|----------|
|
|
2243
|
+
| NFR-PERF-001 | 连接 ≤ 2s | OTP ssh 握手优化,curve25519 优先 |
|
|
2244
|
+
| NFR-PERF-002 | 渲染 ≥ 30fps | Ruby 差分渲染,只重绘变化行 |
|
|
2245
|
+
| NFR-PERF-003 | 输入延迟 ≤ 50ms | Unix Socket IPC ≤ 1ms + Erlang 直发 |
|
|
2246
|
+
| NFR-PERF-004 | SFTP ≥ 50MB/s | Erlang 并行 read/write + 独立通道 |
|
|
2247
|
+
| NFR-PERF-005 | 10 会话 ≤ 500MB | Erlang 轻量进程,每连接约 2-4MB |
|
|
2248
|
+
| NFR-PERF-006 | ≥ 50 并发 | Erlang 进程模型天生支持数万并发 |
|
|
2249
|
+
| NFR-PERF-007 | 搜索 ≤ 500ms | Buffer 索引优化,二分查找 |
|
|
2250
|
+
| NFR-PERF-008 | 冷启动 ≤ 3s | Erlang release 预编译,Ruby 延迟加载 |
|
|
2251
|
+
| NFR-REL-001 | 72h 无崩溃 | OTP 监督树自动恢复,Ruby 线程异常隔离 |
|
|
2252
|
+
| NFR-REL-002 | 重连 ≥ 95% | 指数退避重连 + 会话恢复 |
|
|
2253
|
+
| NFR-REL-003 | 校验 100% | SFTP 传输后 MD5/SHA-256 校验 |
|
|
2254
|
+
| NFR-SEC-001 | 无明文凭据 | Vault 加密 + `~vault:` 引用 |
|
|
2255
|
+
| NFR-SEC-002 | 加密强度 | PBKDF2 ≥ 100000 / Argon2id |
|
|
2256
|
+
| NFR-SEC-003 | 无 CVE | 发布前 `rebar3 audit` + `bundle audit` |
|
|
2257
|
+
| NFR-USE-001 | 5 分钟上手 | CLI 向导 + 文档 |
|
|
2258
|
+
| NFR-USE-002 | 快捷键覆盖 | 全操作快捷键映射 |
|
|
2259
|
+
| NFR-USE-003 | 中英双语 | i18n 资源文件 |
|
|
2260
|
+
| NFR-COMP-001 | OS 兼容 | Erlang 跨平台编译 + Ruby 跨平台 |
|
|
2261
|
+
| NFR-COMP-002 | 服务端兼容 | OTP ssh 兼容 OpenSSH 7.0+、Dropbear、Cisco IOS |
|
|
2262
|
+
| NFR-COMP-003 | HiDPI 适配 | V2.0 TUI 层处理 DPI 缩放 |
|
|
2263
|
+
|
|
2264
|
+
### 9.3 需求覆盖统计
|
|
2265
|
+
|
|
2266
|
+
| 层 | V1.0 覆盖 | V2.0 覆盖 | V3.0 覆盖 | 总计 |
|
|
2267
|
+
|----|----------|----------|----------|------|
|
|
2268
|
+
| 功能需求 | 17/51 | 19/51 | 15/51 | 51/51 |
|
|
2269
|
+
| 非功能需求 | 14/20 | 4/20 | 2/20 | 20/20 |
|
|
2270
|
+
|
|
2271
|
+
---
|
|
2272
|
+
|
|
2273
|
+
## 10 部署与打包
|
|
2274
|
+
|
|
2275
|
+
### 10.1 Erlang 引擎分发
|
|
2276
|
+
|
|
2277
|
+
Erlang 引擎以预编译 release 形式分发,与 Ruby gem 解耦:
|
|
2278
|
+
|
|
2279
|
+
```
|
|
2280
|
+
ssh-core-ext/
|
|
2281
|
+
├── windows-x64/
|
|
2282
|
+
│ └── ssh_core.exe + erts-15.x/
|
|
2283
|
+
├── macos-universal/
|
|
2284
|
+
│ └── ssh_core + erts-15.x/
|
|
2285
|
+
├── linux-x64/
|
|
2286
|
+
│ └── ssh_core + erts-15.x/
|
|
2287
|
+
└── VERSION # 版本信息
|
|
2288
|
+
```
|
|
2289
|
+
|
|
2290
|
+
Ruby gem 在安装时或运行时检测平台,下载对应预编译引擎到 `ext/ssh_core/` 目录。
|
|
2291
|
+
|
|
2292
|
+
### 10.2 Ruby gem 打包
|
|
2293
|
+
|
|
2294
|
+
```ruby
|
|
2295
|
+
# ssh_client.gemspec
|
|
2296
|
+
Gem::Specification.new do |spec|
|
|
2297
|
+
spec.name = "network-infra-utility-ssh"
|
|
2298
|
+
spec.version = "1.0.0"
|
|
2299
|
+
spec.summary = "SSH 连接客户端 - Erlang 核心 + Ruby 调度"
|
|
2300
|
+
spec.authors = ["Numeron"]
|
|
2301
|
+
spec.files = Dir["lib/**/*.rb", "bin/*", "ext/ssh_core/**/*"]
|
|
2302
|
+
spec.bindir = "bin"
|
|
2303
|
+
spec.executables = ["ssh-client"]
|
|
2304
|
+
spec.add_dependency "thor", "~> 1.2"
|
|
2305
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
|
2306
|
+
end
|
|
2307
|
+
```
|
|
2308
|
+
|
|
2309
|
+
### 10.3 启动流程
|
|
2310
|
+
|
|
2311
|
+
```
|
|
2312
|
+
1. 用户执行 ssh-client connect 10.0.0.1 -u admin
|
|
2313
|
+
2. Ruby CLI 解析参数
|
|
2314
|
+
3. SSH::Client.new → 加载配置
|
|
2315
|
+
4. client.start_engine
|
|
2316
|
+
a. 检测平台 → 选择对应 ssh_core 二进制
|
|
2317
|
+
b. Process.spawn 启动 Erlang 引擎
|
|
2318
|
+
c. Erlang 引擎初始化 → 写入 IPC 端点文件
|
|
2319
|
+
d. Ruby 读取端点 → 建立 IPC 连接
|
|
2320
|
+
5. client.connect(spec)
|
|
2321
|
+
a. Vault 解密凭据
|
|
2322
|
+
b. IPC RPC 调用 conn.connect
|
|
2323
|
+
c. Erlang 建立 SSH 连接
|
|
2324
|
+
d. 返回 conn_id
|
|
2325
|
+
6. session.open_terminal
|
|
2326
|
+
a. IPC RPC 调用 channel.open
|
|
2327
|
+
b. Ruby 注册 channel.data 推送回调
|
|
2328
|
+
7. 进入交互模式
|
|
2329
|
+
```
|
|
2330
|
+
|
|
2331
|
+
### 10.4 CI/CD 流水线
|
|
2332
|
+
|
|
2333
|
+
```
|
|
2334
|
+
GitHub Actions / 自建 CI
|
|
2335
|
+
├── Erlang 编译矩阵 (windows/macos/linux)
|
|
2336
|
+
│ ├── rebar3 compile
|
|
2337
|
+
│ ├── rebar3 ct (Common Test)
|
|
2338
|
+
│ ├── rebar3 release
|
|
2339
|
+
│ └── 上传预编译二进制
|
|
2340
|
+
├── Ruby 测试
|
|
2341
|
+
│ ├── bundle install
|
|
2342
|
+
│ ├── rspec spec/
|
|
2343
|
+
│ └── rubocop
|
|
2344
|
+
├── 集成测试
|
|
2345
|
+
│ ├── 启动 Erlang 引擎 + Ruby 客户端
|
|
2346
|
+
│ ├── 连接 Docker 内 SSH 服务器
|
|
2347
|
+
│ └── 执行端到端测试脚本
|
|
2348
|
+
└── 发布
|
|
2349
|
+
├── 打包 Ruby gem (含预编译 Erlang 引擎)
|
|
2350
|
+
└── 发布到 RubyGems / 内部 gem 服务器
|
|
2351
|
+
```
|
|
2352
|
+
|
|
2353
|
+
---
|
|
2354
|
+
|
|
2355
|
+
## 11 三期迭代设计
|
|
2356
|
+
|
|
2357
|
+
### 11.1 V1.0 — MVP 核心(P0 需求)
|
|
2358
|
+
|
|
2359
|
+
**目标**:可用的单用户 SSH 终端,CLI 交互。
|
|
2360
|
+
|
|
2361
|
+
**Erlang 侧交付**:
|
|
2362
|
+
|
|
2363
|
+
| 模块 | 状态 | 说明 |
|
|
2364
|
+
|------|------|------|
|
|
2365
|
+
| ssh_core_sup | 全量 | 监督树 |
|
|
2366
|
+
| ssh_ipc_gateway | 全量 | JSON-RPC 网关 |
|
|
2367
|
+
| ssh_conn_worker | 全量 | SSH2 连接 |
|
|
2368
|
+
| ssh_auth_engine | 全量 | 密码+公钥+键盘交互 |
|
|
2369
|
+
| ssh_jump_chain | 全量 | 多级跳板 |
|
|
2370
|
+
| ssh_channel_fsm | 全量 | shell 通道 |
|
|
2371
|
+
| ssh_keepalive_mgr | 全量 | 保活 + 断连检测 |
|
|
2372
|
+
| ssh_port_fwd | 全量 | 3 种端口转发 |
|
|
2373
|
+
| ssh_sftp_engine | 全量 | SFTP 浏览 + 传输 |
|
|
2374
|
+
| ssh_known_hosts | 全量 | 主机密钥校验 |
|
|
2375
|
+
|
|
2376
|
+
**Ruby 侧交付**:
|
|
2377
|
+
|
|
2378
|
+
| 模块 | 状态 | 说明 |
|
|
2379
|
+
|------|------|------|
|
|
2380
|
+
| SSH::Client | 全量 | 主入口 + 引擎管理 |
|
|
2381
|
+
| IPC::Client | 全量 | JSON-RPC 客户端 |
|
|
2382
|
+
| Session::Manager | 全量 | 会话管理 |
|
|
2383
|
+
| Session::Tree | 全量 | 分组树 |
|
|
2384
|
+
| Terminal::Emulator | 基础 | xterm-256color 转义解析 |
|
|
2385
|
+
| Terminal::Buffer | 全量 | 回滚缓冲 10000 行 |
|
|
2386
|
+
| Terminal::Theme | 全量 | 内置 10 套配色 |
|
|
2387
|
+
| Security::Vault | 全量 | AES-256-GCM |
|
|
2388
|
+
| Security::HostKey | 全量 | 主机密钥管理 |
|
|
2389
|
+
| Automation::MacroEngine | 全量 | 登录宏 |
|
|
2390
|
+
| CLI | 基础 | Thor 命令行 |
|
|
2391
|
+
|
|
2392
|
+
**V1.0 验收标准**:
|
|
2393
|
+
- 连接 OpenSSH 服务器,交互终端可用
|
|
2394
|
+
- 密码 + 公钥 + 跳板连接可用
|
|
2395
|
+
- SFTP 浏览和文件传输可用
|
|
2396
|
+
- 端口转发 3 种类型可用
|
|
2397
|
+
- 凭据加密存储 + 主机密钥校验
|
|
2398
|
+
- 17 条 P0 需求全部满足量化指标
|
|
2399
|
+
|
|
2400
|
+
### 11.2 V2.0 — 效率增强(P1 需求)
|
|
2401
|
+
|
|
2402
|
+
**新增内容**:
|
|
2403
|
+
|
|
2404
|
+
| 方向 | 模块 | 说明 |
|
|
2405
|
+
|------|------|------|
|
|
2406
|
+
| TUI 多面板 | Curses UI + 标签页 + 窗口拆分 | FR-SESS-001 |
|
|
2407
|
+
| 自动重连 | ssh_conn_worker 扩展 | FR-CONN-005 |
|
|
2408
|
+
| 会话导入导出 | Config::ImportExport | FR-SESS-003 |
|
|
2409
|
+
| 会话搜索 | Session::Manager#search | FR-SESS-004 |
|
|
2410
|
+
| 批量文件传输 | File::Transfer 扩展 | FR-FILE-005 |
|
|
2411
|
+
| ZMODEM | File::Zmodem | FR-FILE-004 |
|
|
2412
|
+
| SSH Agent | Security::KeyManager | FR-SEC-003 |
|
|
2413
|
+
| 密钥生成 | Security::KeyManager | FR-SEC-004 |
|
|
2414
|
+
| 代理连接 | Network::Proxy | FR-NET-002 |
|
|
2415
|
+
| 连接诊断 | Network::Diagnostics | FR-NET-004 |
|
|
2416
|
+
| 搜索高亮 | Terminal::Buffer#search 扩展 | FR-TERM-005 |
|
|
2417
|
+
| 广播输入 | Session::Manager#broadcast | FR-TERM-008 |
|
|
2418
|
+
| 代码片段 | Automation::SnippetManager | FR-TERM-009 |
|
|
2419
|
+
| 批量执行 | Automation::BatchExec | FR-AUTO-003 |
|
|
2420
|
+
| 跨平台 | 三平台编译 + 测试 | FR-COLLAB-001 |
|
|
2421
|
+
|
|
2422
|
+
### 11.3 V3.0 — 差异化竞争力(P2 需求)
|
|
2423
|
+
|
|
2424
|
+
**新增内容**:
|
|
2425
|
+
|
|
2426
|
+
| 方向 | 模块 | 说明 |
|
|
2427
|
+
|------|------|------|
|
|
2428
|
+
| MOSH | mosh_engine (Erlang) | FR-CONN-007 |
|
|
2429
|
+
| 会话锁屏 | TUI 层 | FR-SESS-006 |
|
|
2430
|
+
| 远程文件编辑 | File::Watcher | FR-FILE-006 |
|
|
2431
|
+
| 凭据云同步 | Config::Sync + 端到端加密 | FR-SEC-005 |
|
|
2432
|
+
| 2FA/MFA | Security::OTP | FR-SEC-006 |
|
|
2433
|
+
| X11 转发 | ssh_port_fwd 扩展 | FR-NET-003 |
|
|
2434
|
+
| 流量统计 | Network::Stats | FR-NET-005 |
|
|
2435
|
+
| 脚本引擎 | Automation::ScriptEngine (Lua) | FR-AUTO-005 |
|
|
2436
|
+
| 会话录放 | Automation::Recorder | FR-AUTO-002 |
|
|
2437
|
+
| 团队共享 | Config::TeamShare | FR-COLLAB-003 |
|
|
2438
|
+
| 便携版 | 打包脚本 | FR-COLLAB-004 |
|
|
2439
|
+
|
|
2440
|
+
### 11.4 迭代节奏
|
|
2441
|
+
|
|
2442
|
+
| 里程碑 | 时间 | 内容 |
|
|
2443
|
+
|--------|------|------|
|
|
2444
|
+
| M1 | V1.0 W1-2 | Erlang 引擎骨架 + IPC 通路 |
|
|
2445
|
+
| M2 | V1.0 W3-4 | SSH 连接 + 认证 + 跳板 |
|
|
2446
|
+
| M3 | V1.0 W5-6 | Ruby 调度层 + CLI |
|
|
2447
|
+
| M4 | V1.0 W7-8 | 终端仿真 + SFTP + 端口转发 |
|
|
2448
|
+
| M5 | V1.0 W9-10 | 凭据加密 + 已知主机 + 登录宏 |
|
|
2449
|
+
| V1.0 Release | W10 | 17 条 P0 全量验收 |
|
|
2450
|
+
| — | — | — |
|
|
2451
|
+
| M6 | V2.0 W1-4 | TUI 多面板 + 会话管理增强 |
|
|
2452
|
+
| M7 | V2.0 W5-8 | 文件增强 + 网络增强 |
|
|
2453
|
+
| V2.0 Release | W8 | 19 条 P1 全量验收 |
|
|
2454
|
+
| — | — | — |
|
|
2455
|
+
| V3.0 | W1-10 | 15 条 P2 分批交付 |
|
|
2456
|
+
|
|
2457
|
+
---
|
|
2458
|
+
|
|
2459
|
+
## 附录 A:设计决策记录
|
|
2460
|
+
|
|
2461
|
+
### ADR-001 SSH 协议栈选择 Erlang/OTP
|
|
2462
|
+
|
|
2463
|
+
- **日期**:2026-08-02
|
|
2464
|
+
- **状态**:已决定
|
|
2465
|
+
- **背景**:需要在 Windows/macOS/Linux 三平台支持高并发 SSH 连接(≥ 50),连接延迟 ≤ 2s
|
|
2466
|
+
- **决策**:使用 Erlang/OTP 内置 ssh 应用作为协议栈
|
|
2467
|
+
- **理由**:OTP ssh 是官方维护的成熟 SSH2 实现,进程模型天然适合并发连接管理,跨平台编译稳定
|
|
2468
|
+
- **后果**:引入 Erlang/Ruby 双语言栈,增加打包复杂度,但换来得连接层面可靠性
|
|
2469
|
+
|
|
2470
|
+
### ADR-002 IPC 协议选择 JSON-RPC 2.0
|
|
2471
|
+
|
|
2472
|
+
- **日期**:2026-08-02
|
|
2473
|
+
- **状态**:已决定
|
|
2474
|
+
- **背景**:Erlang 与 Ruby 之间需要高效、可靠的进程间通信
|
|
2475
|
+
- **决策**:使用 JSON-RPC 2.0 over Unix Socket / TCP
|
|
2476
|
+
- **理由**:JSON-RPC 语言无关、调试友好;Unix Socket 零拷贝低延迟;JSON 序列化对终端数据性能损失 < 1ms 可接受
|
|
2477
|
+
- **代价**:base64 编码增加约 33% 数据量,终端高频小包场景可通过批量推送优化
|
|
2478
|
+
|
|
2479
|
+
### ADR-003 UI 分阶段:CLI → TUI → GUI
|
|
2480
|
+
|
|
2481
|
+
- **日期**:2026-08-02
|
|
2482
|
+
- **状态**:已决定
|
|
2483
|
+
- **背景**:全量 TUI/GUI 开发周期长,需快速验证核心功能链路
|
|
2484
|
+
- **决策**:V1.0 交付 Thor CLI,V2.0 升级 Curses TUI,V3.0 评估 GUI 需求
|
|
2485
|
+
- **理由**:降低首版复杂度,先跑通 "连接 → 终端 → 文件" 全链路再增强交互
|
|
2486
|
+
|
|
2487
|
+
### ADR-004 凭据存储使用 Ruby 侧 Vault 而非 Erlang 管理
|
|
2488
|
+
|
|
2489
|
+
- **日期**:2026-08-02
|
|
2490
|
+
- **状态**:已决定
|
|
2491
|
+
- **背景**:凭据加密存储可在 Erlang 或 Ruby 任一侧实现
|
|
2492
|
+
- **决策**:Ruby 侧用 AES-256-GCM 管理,连接时解密后经 IPC 传入 Erlang
|
|
2493
|
+
- **理由**:Ruby OpenSSL 生态成熟,与配置管理在同一层更内聚;Erlang 仅作为协议执行层保持无状态
|