network-infra-utility 0.3.0 → 0.6.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/GUIDE.md +212 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +70 -0
- data/Rakefile +1 -1
- data/bin/dns-query +836 -0
- data/bin/geo-doc +135 -0
- data/bin/geo-get +1 -1
- data/bin/geo-update +429 -0
- 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/geodb.rb +278 -1
- data/service/ssh/README.md +955 -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 +117 -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 +142 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
|
|
9
|
+
module NetworkInfraUtility
|
|
10
|
+
module SSH
|
|
11
|
+
module Security
|
|
12
|
+
# 凭据加密存储,AES-256-GCM。
|
|
13
|
+
# 对应需求 FR-SEC-001。
|
|
14
|
+
#
|
|
15
|
+
# 加密参数(HLD §5.2.7 / ADR-004):
|
|
16
|
+
# 算法 AES-256-GCM
|
|
17
|
+
# KDF PBKDF2-HMAC-SHA256
|
|
18
|
+
# 迭代次数 100,000
|
|
19
|
+
# Salt 长度 32 字节(每条凭据独立随机)
|
|
20
|
+
# Key 长度 32 字节
|
|
21
|
+
# Nonce 长度 12 字节(GCM nonce,每次加密随机)
|
|
22
|
+
# Auth Tag GCM 生成,存于 tag 字段
|
|
23
|
+
#
|
|
24
|
+
# vault.yml 每条记录格式:
|
|
25
|
+
# <key>:
|
|
26
|
+
# salt: <Base64>
|
|
27
|
+
# nonce: <Base64>
|
|
28
|
+
# tag: <Base64>
|
|
29
|
+
# data: <Base64>
|
|
30
|
+
#
|
|
31
|
+
# LLD §7.7 修正:
|
|
32
|
+
# - @cache 不在多线程访问(V1.0 主线程唯一),但为 V2.0 batch 预留 @cache_mutex
|
|
33
|
+
# - resolve_credentials 不直接修改传入的 spec,返回新 hash
|
|
34
|
+
class Vault
|
|
35
|
+
PBKDF2_ITERATIONS = 100_000
|
|
36
|
+
SALT_LENGTH = 32
|
|
37
|
+
KEY_LENGTH = 32 # AES-256
|
|
38
|
+
NONCE_LENGTH = 12 # GCM nonce
|
|
39
|
+
REF_PREFIX = "~vault:"
|
|
40
|
+
|
|
41
|
+
attr_reader :store_path
|
|
42
|
+
|
|
43
|
+
# @param master_password [String] 主密码(不落盘,仅内存)
|
|
44
|
+
# @param store_path [String] vault.yml 路径
|
|
45
|
+
def initialize(master_password, store_path = nil)
|
|
46
|
+
raise ArgumentError, "Master password cannot be empty" if master_password.nil? || master_password.empty?
|
|
47
|
+
|
|
48
|
+
@master_password = master_password
|
|
49
|
+
@store_path = store_path || default_store_path
|
|
50
|
+
@cache = {}
|
|
51
|
+
@cache_mutex = Mutex.new # V2.0 batch 预留
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# 加密凭据并存储到 vault.yml。
|
|
55
|
+
# @param key [String] 凭据标识,如 "sess_001_pass"
|
|
56
|
+
# @param value [String] 明文凭据
|
|
57
|
+
def store(key, value)
|
|
58
|
+
salt = SecureRandom.random_bytes(SALT_LENGTH)
|
|
59
|
+
derived_key = derive_key(@master_password, salt)
|
|
60
|
+
nonce = SecureRandom.random_bytes(NONCE_LENGTH)
|
|
61
|
+
|
|
62
|
+
cipher = OpenSSL::Cipher::AES.new(256, :GCM)
|
|
63
|
+
cipher.encrypt
|
|
64
|
+
cipher.key = derived_key
|
|
65
|
+
cipher.iv = nonce
|
|
66
|
+
encrypted = cipher.update(value) + cipher.final
|
|
67
|
+
tag = cipher.auth_tag
|
|
68
|
+
|
|
69
|
+
entry = {
|
|
70
|
+
"salt" => Base64.strict_encode64(salt),
|
|
71
|
+
"nonce" => Base64.strict_encode64(nonce),
|
|
72
|
+
"tag" => Base64.strict_encode64(tag),
|
|
73
|
+
"data" => Base64.strict_encode64(encrypted)
|
|
74
|
+
}
|
|
75
|
+
write_to_store(key, entry)
|
|
76
|
+
@cache_mutex.synchronize { @cache[key] = value }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# 读取并解密凭据。
|
|
80
|
+
# @param key [String] 凭据标识
|
|
81
|
+
# @return [String, nil] 明文凭据,不存在返回 nil
|
|
82
|
+
def load(key)
|
|
83
|
+
cached = @cache_mutex.synchronize { @cache[key] }
|
|
84
|
+
return cached if cached
|
|
85
|
+
|
|
86
|
+
entry = read_from_store(key)
|
|
87
|
+
return nil unless entry
|
|
88
|
+
|
|
89
|
+
plaintext = decrypt_entry(entry)
|
|
90
|
+
@cache_mutex.synchronize { @cache[key] = plaintext }
|
|
91
|
+
plaintext
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# 删除凭据
|
|
95
|
+
# @param key [String] 凭据标识
|
|
96
|
+
def delete(key)
|
|
97
|
+
store = load_store
|
|
98
|
+
store.delete(key)
|
|
99
|
+
write_store(store)
|
|
100
|
+
@cache_mutex.synchronize { @cache.delete(key) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# 列出全部凭据标识
|
|
104
|
+
# @return [Array<String>]
|
|
105
|
+
def keys
|
|
106
|
+
load_store.keys.map(&:to_s)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# 在连接规格中解析凭据引用。
|
|
110
|
+
# spec 中值为 "~vault:<key>" 的字段会被替换为解密后的明文。
|
|
111
|
+
# LLD §7.7 修正:返回新 hash,不修改入参。
|
|
112
|
+
# @param spec [Hash] 连接规格
|
|
113
|
+
# @return [Hash] 新的连接规格,凭据引用已解析为明文
|
|
114
|
+
def resolve_credentials(spec)
|
|
115
|
+
spec.each_with_object({}) do |(k, v), result|
|
|
116
|
+
result[k] = resolve_value(v)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# 检查凭据标识是否是 vault 引用
|
|
121
|
+
# @param str [String]
|
|
122
|
+
# @return [Boolean]
|
|
123
|
+
def vault_ref?(str)
|
|
124
|
+
str.is_a?(String) && str.start_with?(REF_PREFIX)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# 递归解析 spec 中的 vault 引用(处理嵌套 hash 和数组)
|
|
130
|
+
def resolve_value(v)
|
|
131
|
+
case v
|
|
132
|
+
when String
|
|
133
|
+
v.start_with?(REF_PREFIX) ? load(v.sub(REF_PREFIX, "")) : v
|
|
134
|
+
when Hash
|
|
135
|
+
v.transform_values { |val| resolve_value(val) }
|
|
136
|
+
when Array
|
|
137
|
+
v.map { |val| resolve_value(val) }
|
|
138
|
+
else
|
|
139
|
+
v
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def derive_key(password, salt)
|
|
144
|
+
OpenSSL::PKCS5.pbkdf2_hmac(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def decrypt_entry(entry)
|
|
148
|
+
salt = Base64.strict_decode64(entry["salt"])
|
|
149
|
+
nonce = Base64.strict_decode64(entry["nonce"])
|
|
150
|
+
tag = Base64.strict_decode64(entry["tag"])
|
|
151
|
+
encrypted = Base64.strict_decode64(entry["data"])
|
|
152
|
+
|
|
153
|
+
derived_key = derive_key(@master_password, salt)
|
|
154
|
+
|
|
155
|
+
cipher = OpenSSL::Cipher::AES.new(256, :GCM)
|
|
156
|
+
cipher.decrypt
|
|
157
|
+
cipher.key = derived_key
|
|
158
|
+
cipher.iv = nonce
|
|
159
|
+
cipher.auth_tag = tag
|
|
160
|
+
|
|
161
|
+
cipher.update(encrypted) + cipher.final
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def write_to_store(key, entry)
|
|
165
|
+
store = load_store
|
|
166
|
+
store[key] = entry
|
|
167
|
+
write_store(store)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def read_from_store(key)
|
|
171
|
+
load_store[key]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def load_store
|
|
175
|
+
return {} unless File.exist?(@store_path)
|
|
176
|
+
|
|
177
|
+
# 使用字符串 key 而非 Symbol:safe_load 不允许 Symbol,
|
|
178
|
+
# 消除 permitted_classes: [Symbol] 带来的反序列化攻击面。
|
|
179
|
+
# 写入端 write_store 也已改为字符串 key。
|
|
180
|
+
YAML.safe_load(File.read(@store_path)) || {}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def write_store(store)
|
|
184
|
+
FileUtils.mkdir_p(File.dirname(@store_path))
|
|
185
|
+
File.write(@store_path, YAML.dump(store))
|
|
186
|
+
set_file_permissions
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def default_store_path
|
|
190
|
+
File.join(Dir.home, ".network-infra-utility", "vault.yml")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def set_file_permissions
|
|
194
|
+
path = @store_path
|
|
195
|
+
if Gem.win_platform?
|
|
196
|
+
# Windows: ACL 限制当前用户读写
|
|
197
|
+
# 使用 ENV.fetch 避免注入:icacls 的用户名参数不转义特殊字符会导致命令注入
|
|
198
|
+
user = ENV["USERNAME"] || ENV["USER"] || ""
|
|
199
|
+
# 白名单校验用户名(Windows 用户名仅允许字母、数字、连字符、下划线和点)
|
|
200
|
+
unless user.match(/\A[A-Za-z0-9._-]+\z/)
|
|
201
|
+
raise SecurityError, "Invalid username for ACL: #{user.inspect}"
|
|
202
|
+
end
|
|
203
|
+
system("icacls", path, "/inheritance:r", "/grant:r", "#{user}:F")
|
|
204
|
+
else
|
|
205
|
+
File.chmod(0o600, path)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NetworkInfraUtility
|
|
4
|
+
module SSH
|
|
5
|
+
module Session
|
|
6
|
+
# 最近连接列表与历史记录(FR-SESS-005)。
|
|
7
|
+
# 在内存中维护,可持久化到配置。
|
|
8
|
+
class History
|
|
9
|
+
MAX_ITEMS = 20
|
|
10
|
+
|
|
11
|
+
attr_reader :items, :pinned
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@items = [] # [Session] 按使用时间倒序
|
|
15
|
+
@pinned = {} # conn_id => true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# 记录一次连接
|
|
19
|
+
def record(session)
|
|
20
|
+
@items.delete(session)
|
|
21
|
+
@items.unshift(session)
|
|
22
|
+
trim
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# 置顶
|
|
26
|
+
def pin(conn_id)
|
|
27
|
+
@pinned[conn_id] = true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# 取消置顶
|
|
31
|
+
def unpin(conn_id)
|
|
32
|
+
@pinned.delete(conn_id)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 获取最近列表(置顶在前)
|
|
36
|
+
def recent
|
|
37
|
+
pinned_sessions = @items.select { |s| @pinned[s.conn_id] }
|
|
38
|
+
unpinned = @items.reject { |s| @pinned[s.conn_id] }
|
|
39
|
+
pinned_sessions + unpinned
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# 清除
|
|
43
|
+
def clear
|
|
44
|
+
@items.clear
|
|
45
|
+
@pinned.clear
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def trim
|
|
51
|
+
@items = @items.first(MAX_ITEMS)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session"
|
|
4
|
+
require_relative "tree"
|
|
5
|
+
require_relative "history"
|
|
6
|
+
|
|
7
|
+
module NetworkInfraUtility
|
|
8
|
+
module SSH
|
|
9
|
+
module Session
|
|
10
|
+
# 会话管理器:维护全部活跃会话的生命周期。
|
|
11
|
+
# 对应需求 FR-SESS-001 ~ FR-SESS-006。
|
|
12
|
+
class Manager
|
|
13
|
+
include Enumerable
|
|
14
|
+
|
|
15
|
+
attr_reader :tree, :history
|
|
16
|
+
|
|
17
|
+
def initialize(client)
|
|
18
|
+
@client = client
|
|
19
|
+
@sessions = {} # conn_id => Session
|
|
20
|
+
@tree = Tree.new
|
|
21
|
+
@history = History.new
|
|
22
|
+
setup_conn_closed_handler
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# 从已有连接 ID 创建会话对象
|
|
26
|
+
def create(conn_id, spec, fingerprint = nil)
|
|
27
|
+
session = Session.new(@client, conn_id, spec, fingerprint)
|
|
28
|
+
@sessions[conn_id] = session
|
|
29
|
+
@history.record(session)
|
|
30
|
+
session
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get(conn_id)
|
|
34
|
+
@sessions[conn_id]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def disconnect(conn_id)
|
|
38
|
+
@sessions[conn_id]&.disconnect
|
|
39
|
+
@sessions.delete(conn_id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def disconnect_all
|
|
43
|
+
@sessions.each_value(&:disconnect)
|
|
44
|
+
@sessions.clear
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def each(&block)
|
|
48
|
+
@sessions.each_value(&block)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def count
|
|
52
|
+
@sessions.size
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 搜索会话(FR-SESS-004)
|
|
56
|
+
# @param query [String] 搜索关键词
|
|
57
|
+
# @param fields [Array<Symbol>] 搜索字段,默认 :name, :host, :ip, :tags
|
|
58
|
+
def search(query, fields = %i[name host ip tags])
|
|
59
|
+
query_lower = query.downcase
|
|
60
|
+
select do |s|
|
|
61
|
+
fields.any? do |f|
|
|
62
|
+
val = s.send(f)
|
|
63
|
+
if val.is_a?(Array)
|
|
64
|
+
val.any? { |v| v.to_s.downcase.include?(query_lower) }
|
|
65
|
+
else
|
|
66
|
+
val.to_s.downcase.include?(query_lower)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# 广播输入到多个会话(FR-TERM-008)
|
|
73
|
+
# @param conn_ids [Array<String>] 目标连接 ID
|
|
74
|
+
# @param text [String] 要发送的文本
|
|
75
|
+
def broadcast(conn_ids, text)
|
|
76
|
+
conn_ids.each { |id| @sessions[id]&.terminal&.send(text) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# 监听 conn.closed 推送,清理会话
|
|
82
|
+
def setup_conn_closed_handler
|
|
83
|
+
@client.ipc.subscribe("conn.closed") do |params|
|
|
84
|
+
conn_id = params[:conn_id]
|
|
85
|
+
session = @sessions.delete(conn_id)
|
|
86
|
+
session&.on_closed(params[:reason])
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "../terminal/emulator"
|
|
5
|
+
|
|
6
|
+
module NetworkInfraUtility
|
|
7
|
+
module SSH
|
|
8
|
+
module Session
|
|
9
|
+
# 单个 SSH 会话:封装终端、文件、端口转发等子能力。
|
|
10
|
+
class Session
|
|
11
|
+
attr_reader :session_id, :conn_id, :spec, :name, :host, :port, :user
|
|
12
|
+
attr_reader :fingerprint, :terminal, :file_manager, :port_forwards
|
|
13
|
+
attr_accessor :tags, :group, :status
|
|
14
|
+
|
|
15
|
+
STATUSES = %i[connecting authenticating connected disconnected error].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(client, conn_id, spec, fingerprint = nil)
|
|
18
|
+
@client = client
|
|
19
|
+
@conn_id = conn_id
|
|
20
|
+
@session_id = "sess_#{SecureRandom.uuid.delete('-')}"
|
|
21
|
+
@spec = spec
|
|
22
|
+
@fingerprint = fingerprint
|
|
23
|
+
@name = spec[:name] || "#{spec[:user]}@#{spec[:host]}"
|
|
24
|
+
@host = spec[:host]
|
|
25
|
+
@port = spec[:port] || 22
|
|
26
|
+
@user = spec[:user]
|
|
27
|
+
@tags = spec[:tags] || []
|
|
28
|
+
@group = spec[:group]
|
|
29
|
+
@status = :connected
|
|
30
|
+
@terminal = nil
|
|
31
|
+
@file_manager = nil
|
|
32
|
+
@port_forwards = []
|
|
33
|
+
@coalesce = client.coalesce
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 打开终端通道
|
|
37
|
+
# @param term_type [String] 终端类型如 "xterm-256color"
|
|
38
|
+
# @param cols [Integer] 列数
|
|
39
|
+
# @param rows [Integer] 行数
|
|
40
|
+
def open_terminal(term_type: "xterm-256color", cols: 80, rows: 24)
|
|
41
|
+
raise "Terminal already open" if @terminal
|
|
42
|
+
|
|
43
|
+
resp = @client.ipc.call("channel.open", {
|
|
44
|
+
conn_id: @conn_id,
|
|
45
|
+
type: "shell",
|
|
46
|
+
term: term_type,
|
|
47
|
+
cols: cols,
|
|
48
|
+
rows: rows
|
|
49
|
+
})
|
|
50
|
+
ch_id = resp[:channel_id]
|
|
51
|
+
@terminal = Terminal::Emulator.new(@client, @conn_id, ch_id, cols, rows,
|
|
52
|
+
theme: @spec[:terminal]&.dig(:theme))
|
|
53
|
+
@coalesce&.register_channel(@conn_id, ch_id, @terminal)
|
|
54
|
+
@terminal
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# 关闭终端
|
|
58
|
+
def close_terminal
|
|
59
|
+
return unless @terminal
|
|
60
|
+
|
|
61
|
+
@client.ipc.call("channel.close", { id: @terminal.channel_id })
|
|
62
|
+
@coalesce&.unregister_channel(@conn_id, @terminal.channel_id)
|
|
63
|
+
@terminal = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 添加端口转发规则(FR-NET-001)
|
|
67
|
+
def add_port_forward(type, local_port, remote_host, remote_port)
|
|
68
|
+
resp = @client.ipc.call("portfwd.add", {
|
|
69
|
+
conn_id: @conn_id,
|
|
70
|
+
type: type.to_s,
|
|
71
|
+
local_port: local_port,
|
|
72
|
+
remote_host: remote_host,
|
|
73
|
+
remote_port: remote_port
|
|
74
|
+
})
|
|
75
|
+
rule_id = resp[:rule_id]
|
|
76
|
+
@port_forwards << {
|
|
77
|
+
id: rule_id, type: type, local_port: local_port,
|
|
78
|
+
remote_host: remote_host, remote_port: remote_port
|
|
79
|
+
}
|
|
80
|
+
rule_id
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# 移除端口转发规则
|
|
84
|
+
def remove_port_forward(rule_id)
|
|
85
|
+
@client.ipc.call("portfwd.remove", { conn_id: @conn_id, rule_id: rule_id })
|
|
86
|
+
@port_forwards.reject! { |r| r[:id] == rule_id }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# 断开连接
|
|
90
|
+
def disconnect
|
|
91
|
+
close_terminal
|
|
92
|
+
@client.ipc.call("conn.disconnect", { id: @conn_id })
|
|
93
|
+
@status = :disconnected
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def connected?
|
|
97
|
+
@status == :connected
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# 连接被远端关闭时的回调
|
|
101
|
+
def on_closed(reason = nil)
|
|
102
|
+
@status = :disconnected
|
|
103
|
+
@terminal = nil
|
|
104
|
+
@file_manager = nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module NetworkInfraUtility
|
|
6
|
+
module SSH
|
|
7
|
+
module Session
|
|
8
|
+
# 会话分组的树形管理(FR-SESS-002)。
|
|
9
|
+
# 支持 ≥ 5 层嵌套、拖拽移动、折叠/展开、分组级批量操作。
|
|
10
|
+
class Tree
|
|
11
|
+
attr_reader :root
|
|
12
|
+
|
|
13
|
+
Node = Struct.new(:id, :name, :parent, :children, :collapsed, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@root = Node.new(id: "root", name: "Root", parent: nil, children: [], collapsed: false)
|
|
17
|
+
@nodes = { "root" => @root }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# 添加分组
|
|
21
|
+
def add_group(name, parent_id: "root")
|
|
22
|
+
id = "grp_#{SecureRandom.hex(4)}"
|
|
23
|
+
parent = @nodes[parent_id]
|
|
24
|
+
raise "Parent group not found: #{parent_id}" unless parent
|
|
25
|
+
|
|
26
|
+
node = Node.new(id: id, name: name, parent: parent, children: [], collapsed: false)
|
|
27
|
+
parent.children << node
|
|
28
|
+
@nodes[id] = node
|
|
29
|
+
id
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# 删除分组(子分组递归删除)
|
|
33
|
+
def remove_group(group_id)
|
|
34
|
+
node = @nodes.delete(group_id)
|
|
35
|
+
return unless node
|
|
36
|
+
|
|
37
|
+
node.parent&.children&.delete(node)
|
|
38
|
+
node.children.each { |c| remove_group(c.id) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 重命名分组
|
|
42
|
+
def rename_group(group_id, new_name)
|
|
43
|
+
@nodes[group_id]&.name = new_name
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 折叠/展开
|
|
47
|
+
def toggle_collapse(group_id)
|
|
48
|
+
node = @nodes[group_id]
|
|
49
|
+
return unless node
|
|
50
|
+
|
|
51
|
+
node.collapsed = !node.collapsed
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# 移动分组到新父节点
|
|
55
|
+
def move_group(group_id, new_parent_id)
|
|
56
|
+
node = @nodes[group_id]
|
|
57
|
+
new_parent = @nodes[new_parent_id]
|
|
58
|
+
return unless node && new_parent
|
|
59
|
+
|
|
60
|
+
node.parent&.children&.delete(node)
|
|
61
|
+
node.parent = new_parent
|
|
62
|
+
new_parent.children << node
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# 查找分组
|
|
66
|
+
def find_group(group_id)
|
|
67
|
+
@nodes[group_id]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# 遍历所有分组(深度优先)
|
|
71
|
+
def each_group(&block)
|
|
72
|
+
traverse(@root, &block)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# 获取分组的完整路径名
|
|
76
|
+
def group_path(group_id)
|
|
77
|
+
parts = []
|
|
78
|
+
node = @nodes[group_id]
|
|
79
|
+
while node && node.id != "root"
|
|
80
|
+
parts.unshift(node.name)
|
|
81
|
+
node = node.parent
|
|
82
|
+
end
|
|
83
|
+
parts.join(" / ")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def traverse(node, &block)
|
|
89
|
+
block.call(node) unless node.id == "root"
|
|
90
|
+
node.children.each { |c| traverse(c, &block) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|