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,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
8
|
+
require_relative "ipc/transport"
|
|
9
|
+
require_relative "ipc/router"
|
|
10
|
+
require_relative "ipc/coalesce"
|
|
11
|
+
require_relative "ipc/errors"
|
|
12
|
+
require_relative "session/manager"
|
|
13
|
+
require_relative "security/vault"
|
|
14
|
+
require_relative "security/host_key"
|
|
15
|
+
require_relative "config/settings"
|
|
16
|
+
require_relative "config/schema"
|
|
17
|
+
require_relative "config/store"
|
|
18
|
+
|
|
19
|
+
module NetworkInfraUtility
|
|
20
|
+
module SSH
|
|
21
|
+
# SSH 客户端主入口:管理引擎生命周期与模块协调。
|
|
22
|
+
#
|
|
23
|
+
# 用法:
|
|
24
|
+
# client = SSH::Client.new
|
|
25
|
+
# client.start_engine
|
|
26
|
+
# session = client.connect(host: "10.0.0.1", user: "admin")
|
|
27
|
+
# terminal = session.open_terminal
|
|
28
|
+
# terminal.puts("show version")
|
|
29
|
+
class Client
|
|
30
|
+
PROTOCOL_VER = "1.0"
|
|
31
|
+
ENGINE_STARTUP_TIMEOUT = 10 # 秒
|
|
32
|
+
# 默认引擎后端::rust 或 :erlang
|
|
33
|
+
DEFAULT_BACKEND = :rust
|
|
34
|
+
# 引擎进程退出检测轮询间隔(秒)
|
|
35
|
+
ENGINE_WATCH_INTERVAL = 2
|
|
36
|
+
|
|
37
|
+
attr_reader :ipc, :sessions, :vault, :settings, :coalesce, :host_key, :backend
|
|
38
|
+
|
|
39
|
+
def initialize(config_dir: nil, backend: nil)
|
|
40
|
+
@settings = Config::Settings.new(config_dir)
|
|
41
|
+
@vault = Security::Vault.new(@settings.master_password)
|
|
42
|
+
@host_key = Security::HostKey.new(@settings.known_hosts_path)
|
|
43
|
+
@ipc = IPC::Router.new(IPC::Transport.new)
|
|
44
|
+
@sessions = Session::Manager.new(self)
|
|
45
|
+
@coalesce = nil
|
|
46
|
+
@engine_pid = nil
|
|
47
|
+
@engine_mutex = Mutex.new
|
|
48
|
+
@started = false
|
|
49
|
+
@backend = backend || DEFAULT_BACKEND
|
|
50
|
+
@engine_watcher = nil
|
|
51
|
+
@exit_callback = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# 设置引擎异常退出回调
|
|
55
|
+
# 回调签名: ->(reason) { }
|
|
56
|
+
# reason 为 :crashed(非零退出)或 :disconnected(IPC 连接断开)
|
|
57
|
+
def on_engine_exit(&block)
|
|
58
|
+
@exit_callback = block
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 启动 SSH 核心引擎,建立 IPC 连接。
|
|
62
|
+
def start_engine
|
|
63
|
+
@engine_mutex.synchronize do
|
|
64
|
+
raise "Engine already started" if @started
|
|
65
|
+
|
|
66
|
+
@engine_pid = spawn_engine
|
|
67
|
+
# 分离子进程但仍可 wait——Process.detach 返回一个线程,
|
|
68
|
+
# 如果子进程异常退出,线程能捕获退出码
|
|
69
|
+
@engine_watcher = Process.detach(@engine_pid)
|
|
70
|
+
endpoint, token = wait_for_endpoint
|
|
71
|
+
@ipc.connect(endpoint, auth_token: token, client_id: "ruby-#{Process.pid}")
|
|
72
|
+
|
|
73
|
+
# 注册 IPC 断连回调
|
|
74
|
+
@ipc.on_disconnect do |reason|
|
|
75
|
+
handle_engine_disconnect(reason)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# 注册反向 RPC:hostkey.resolve
|
|
79
|
+
register_reverse_handlers
|
|
80
|
+
|
|
81
|
+
# 初始化 coalesce(需在 hello 之后,获取服务端 capabilities)
|
|
82
|
+
@coalesce = IPC::Coalesce.new(@ipc)
|
|
83
|
+
@started = true
|
|
84
|
+
|
|
85
|
+
# 启动引擎退出监控线程
|
|
86
|
+
start_engine_monitor
|
|
87
|
+
end
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# 发起 SSH 连接
|
|
92
|
+
# @param spec [Hash] 连接参数 {host, port, user, auth, jumps, ...}
|
|
93
|
+
# @return [Session::Session] 会话对象
|
|
94
|
+
def connect(spec)
|
|
95
|
+
ensure_started
|
|
96
|
+
# Vault 解析凭据引用,返回新 hash(不改入参)
|
|
97
|
+
resolved_spec = @vault.resolve_credentials(spec)
|
|
98
|
+
conn_resp = @ipc.call("conn.connect", resolved_spec, timeout_ms: 60_000)
|
|
99
|
+
@sessions.create(conn_resp[:conn_id], spec, conn_resp[:fingerprint])
|
|
100
|
+
rescue IPC::RPCError => e
|
|
101
|
+
raise ConnectionError, e.message
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# 停止引擎,清理资源
|
|
105
|
+
def stop
|
|
106
|
+
@engine_mutex.synchronize do
|
|
107
|
+
return unless @started
|
|
108
|
+
|
|
109
|
+
@engine_monitor&.kill
|
|
110
|
+
@engine_monitor = nil
|
|
111
|
+
@sessions.disconnect_all
|
|
112
|
+
@ipc.call("bye", {}, timeout_ms: 5_000) rescue nil
|
|
113
|
+
@ipc.close
|
|
114
|
+
if @engine_pid
|
|
115
|
+
begin
|
|
116
|
+
Process.kill("TERM", @engine_pid)
|
|
117
|
+
# 等待最多 3 秒让引擎优雅退出
|
|
118
|
+
Timeout.timeout(3) { @engine_watcher&.join } if @engine_watcher
|
|
119
|
+
rescue => e
|
|
120
|
+
# 引擎可能已退出,忽略
|
|
121
|
+
rescue Timeout::Error
|
|
122
|
+
# 强制杀死
|
|
123
|
+
Process.kill("KILL", @engine_pid) rescue nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
@engine_pid = nil
|
|
127
|
+
@engine_watcher = nil
|
|
128
|
+
@started = false
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# 引擎是否已启动
|
|
133
|
+
def started?
|
|
134
|
+
@started
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def ensure_started
|
|
140
|
+
start_engine unless @started
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# 引擎是否存活
|
|
144
|
+
def engine_alive?
|
|
145
|
+
return false unless @engine_pid
|
|
146
|
+
# Process.detach 返回的线程,如果已结束说明子进程已退出
|
|
147
|
+
return false if @engine_watcher&.join(0)
|
|
148
|
+
true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# 启动引擎退出监控线程
|
|
152
|
+
def start_engine_monitor
|
|
153
|
+
@engine_monitor = Thread.new do
|
|
154
|
+
loop do
|
|
155
|
+
sleep ENGINE_WATCH_INTERVAL
|
|
156
|
+
# 检查子进程是否已退出
|
|
157
|
+
if @engine_watcher&.join(0)
|
|
158
|
+
exit_status = @engine_watcher.value rescue nil
|
|
159
|
+
reason = if exit_status && exit_status != 0
|
|
160
|
+
:crashed
|
|
161
|
+
else
|
|
162
|
+
:exited
|
|
163
|
+
end
|
|
164
|
+
handle_engine_exit(reason)
|
|
165
|
+
break
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
rescue => e
|
|
169
|
+
# 监控线程异常,不中断主流程
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# 引擎异常退出时的处理
|
|
174
|
+
def handle_engine_exit(reason)
|
|
175
|
+
@started = false
|
|
176
|
+
@sessions.disconnect_all
|
|
177
|
+
@exit_callback&.call(reason)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# IPC 连接断开时的处理
|
|
181
|
+
def handle_engine_disconnect(reason)
|
|
182
|
+
@started = false
|
|
183
|
+
@sessions.mark_all_disconnected
|
|
184
|
+
@exit_callback&.call(:disconnected)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# ----------------------------------------------------------------
|
|
188
|
+
# 以下为 private 方法(增强版修复后保留,旧版重复定义已移除)
|
|
189
|
+
# ----------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
def ensure_started
|
|
194
|
+
start_engine unless @started
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# 拉起 Erlang 引擎子进程
|
|
198
|
+
def spawn_engine
|
|
199
|
+
binary = engine_binary_path
|
|
200
|
+
raise "Engine binary not found: #{binary}" unless File.exist?(binary)
|
|
201
|
+
|
|
202
|
+
log_dir = @settings.log_dir
|
|
203
|
+
FileUtils.mkdir_p(log_dir)
|
|
204
|
+
if Gem.win_platform?
|
|
205
|
+
# Windows: .cmd files must be invoked via cmd.exe
|
|
206
|
+
Process.spawn(
|
|
207
|
+
"cmd", "/c", binary,
|
|
208
|
+
{ out: File.join(log_dir, "engine.log"),
|
|
209
|
+
err: File.join(log_dir, "engine.err") }
|
|
210
|
+
)
|
|
211
|
+
else
|
|
212
|
+
Process.spawn(
|
|
213
|
+
binary,
|
|
214
|
+
{ out: File.join(log_dir, "engine.log"),
|
|
215
|
+
err: File.join(log_dir, "engine.err") }
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# 等待 Erlang 引擎写入端点文件
|
|
221
|
+
def wait_for_endpoint
|
|
222
|
+
endpoint_file = @settings.engine_endpoint_file
|
|
223
|
+
deadline = Time.now + ENGINE_STARTUP_TIMEOUT
|
|
224
|
+
loop do
|
|
225
|
+
if File.exist?(endpoint_file)
|
|
226
|
+
content = File.read(endpoint_file).strip.split("\n")
|
|
227
|
+
return content[0], content[1]
|
|
228
|
+
end
|
|
229
|
+
raise "Erlang engine startup timeout" if Time.now > deadline
|
|
230
|
+
|
|
231
|
+
sleep 0.1
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# 注册反向 RPC 处理器
|
|
236
|
+
def register_reverse_handlers
|
|
237
|
+
@ipc.on_reverse_rpc("hostkey.resolve") do |params|
|
|
238
|
+
@host_key.resolve(params[:host], params[:port], params[:fingerprint])
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def engine_binary_path
|
|
243
|
+
ext_dir = File.expand_path("../../../ext", __dir__)
|
|
244
|
+
base = case @backend
|
|
245
|
+
when :rust
|
|
246
|
+
File.join(ext_dir, "ssh_core_rs", "bin", "ssh_core_rs")
|
|
247
|
+
else
|
|
248
|
+
File.join(ext_dir, "ssh_core", "bin", "ssh_core")
|
|
249
|
+
end
|
|
250
|
+
Gem.win_platform? ? base + ".cmd" : base
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
class ConnectionError < StandardError; end
|
|
255
|
+
class AlreadyStarted < StandardError; end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NetworkInfraUtility
|
|
4
|
+
module SSH
|
|
5
|
+
module Config
|
|
6
|
+
# 配置校验与版本迁移。
|
|
7
|
+
class SchemaError < StandardError; end
|
|
8
|
+
|
|
9
|
+
module Schema
|
|
10
|
+
SCHEMA_VERSION = 1
|
|
11
|
+
|
|
12
|
+
# 会话字段定义
|
|
13
|
+
SESSION_FIELDS = {
|
|
14
|
+
id: String, name: String, group: String, host: String, port: Integer,
|
|
15
|
+
user: String, tags: Array, auth: Hash, terminal: Hash,
|
|
16
|
+
keepalive: Hash, proxy: Hash, jumps: Array, port_forwards: Array,
|
|
17
|
+
macro: Hash, log: Hash, auto_reconnect: Hash,
|
|
18
|
+
algorithms: Hash, connect_timeout_ms: Integer
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# 分组字段定义
|
|
22
|
+
GROUP_FIELDS = {
|
|
23
|
+
id: String, name: String, parent: String, collapsed: [TrueClass, FalseClass]
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# @param doc [Hash] 加载的 YAML 文档
|
|
27
|
+
# @return [:ok] 校验通过
|
|
28
|
+
# @raise [SchemaError] 校验失败
|
|
29
|
+
def self.validate(doc)
|
|
30
|
+
raise SchemaError, "version mismatch" unless doc[:version] == SCHEMA_VERSION
|
|
31
|
+
|
|
32
|
+
(doc[:sessions] || []).each_with_index do |s, i|
|
|
33
|
+
validate_session(s, i)
|
|
34
|
+
end
|
|
35
|
+
(doc[:groups] || []).each_with_index do |g, i|
|
|
36
|
+
validate_group(g, i)
|
|
37
|
+
end
|
|
38
|
+
:ok
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 迁移旧版本到当前版本
|
|
42
|
+
def self.migrate(doc)
|
|
43
|
+
# V1.0 仅 v1,预留迁移接口
|
|
44
|
+
doc
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def validate_session(session, index)
|
|
51
|
+
SESSION_FIELDS.each do |field, type|
|
|
52
|
+
val = session[field]
|
|
53
|
+
next if val.nil?
|
|
54
|
+
|
|
55
|
+
unless valid_type?(val, type)
|
|
56
|
+
raise SchemaError, "Session[#{index}] field '#{field}' type mismatch: expected #{type}, got #{val.class}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
raise SchemaError, "Session[#{index}] missing required host" unless session[:host]
|
|
61
|
+
raise SchemaError, "Session[#{index}] missing required user" unless session[:user]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_group(group, index)
|
|
65
|
+
GROUP_FIELDS.each do |field, type|
|
|
66
|
+
val = group[field]
|
|
67
|
+
next if val.nil?
|
|
68
|
+
|
|
69
|
+
unless valid_type?(val, type)
|
|
70
|
+
raise SchemaError, "Group[#{index}] field '#{field}' type mismatch"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def valid_type?(val, type)
|
|
76
|
+
case type
|
|
77
|
+
when Array
|
|
78
|
+
# 数组类型:每个元素都是一个可接受类型(联合类型)
|
|
79
|
+
type.any? { |t| valid_type?(val, t) }
|
|
80
|
+
when Class
|
|
81
|
+
val.is_a?(type)
|
|
82
|
+
else
|
|
83
|
+
val.is_a?(type)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module NetworkInfraUtility
|
|
8
|
+
module SSH
|
|
9
|
+
module Config
|
|
10
|
+
# 全局设置加载。
|
|
11
|
+
class Settings
|
|
12
|
+
DEFAULT_CONFIG_DIR = File.expand_path("~/.network-infra-utility")
|
|
13
|
+
DEFAULT_SETTINGS = {
|
|
14
|
+
master_password: nil,
|
|
15
|
+
log_dir: nil,
|
|
16
|
+
config_dir: DEFAULT_CONFIG_DIR,
|
|
17
|
+
default_terminal: "xterm-256color",
|
|
18
|
+
default_scrollback: 10_000,
|
|
19
|
+
default_keepalive_interval: 30,
|
|
20
|
+
default_theme: "dark"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :config_dir, :data
|
|
24
|
+
|
|
25
|
+
def initialize(config_dir = nil)
|
|
26
|
+
@config_dir = config_dir || DEFAULT_CONFIG_DIR
|
|
27
|
+
@data = load_settings
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def master_password
|
|
31
|
+
@data[:master_password]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def master_password=(pwd)
|
|
35
|
+
@data[:master_password] = pwd
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def log_dir
|
|
39
|
+
@data[:log_dir] || File.join(@config_dir, "logs")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def sessions_path
|
|
43
|
+
File.join(@config_dir, "sessions.yml")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def vault_path
|
|
47
|
+
File.join(@config_dir, "vault.yml")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def known_hosts_path
|
|
51
|
+
File.join(@config_dir, "known_hosts.yml")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def settings_path
|
|
55
|
+
File.join(@config_dir, "settings.yml")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def engine_endpoint_file
|
|
59
|
+
uid = if Gem.win_platform?
|
|
60
|
+
ENV["USERNAME"] || "default"
|
|
61
|
+
else
|
|
62
|
+
Process.uid.to_s
|
|
63
|
+
end
|
|
64
|
+
File.join(Dir.tmpdir, "ssh_core_#{uid}.endpoint")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def default_terminal
|
|
68
|
+
@data[:default_terminal]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def default_scrollback
|
|
72
|
+
@data[:default_scrollback]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def default_keepalive_interval
|
|
76
|
+
@data[:default_keepalive_interval]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def default_theme
|
|
80
|
+
@data[:default_theme]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def windows?
|
|
86
|
+
RUBY_PLATFORM =~ /mswin|mingw|cygwin/
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def load_settings
|
|
90
|
+
path = settings_path
|
|
91
|
+
defaults = DEFAULT_SETTINGS.merge(config_dir: @config_dir)
|
|
92
|
+
|
|
93
|
+
if File.exist?(path)
|
|
94
|
+
loaded = YAML.safe_load(File.read(path), symbolize_names: true) || {}
|
|
95
|
+
defaults.merge(loaded)
|
|
96
|
+
else
|
|
97
|
+
defaults
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "schema"
|
|
6
|
+
|
|
7
|
+
module NetworkInfraUtility
|
|
8
|
+
module SSH
|
|
9
|
+
module Config
|
|
10
|
+
# 会话/分组/片段的统一持久化。
|
|
11
|
+
# 原子写:先写 .tmp 再 rename,防止崩溃导致配置损坏。
|
|
12
|
+
class Store
|
|
13
|
+
attr_reader :path, :doc
|
|
14
|
+
|
|
15
|
+
def initialize(path)
|
|
16
|
+
@path = path
|
|
17
|
+
@doc = load
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def sessions
|
|
21
|
+
@doc[:sessions] || []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def groups
|
|
25
|
+
@doc[:groups] || []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def find_session(id)
|
|
29
|
+
sessions.find { |s| s[:id] == id }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def find_group(id)
|
|
33
|
+
groups.find { |g| g[:id] == id }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def add_session(session)
|
|
37
|
+
@doc[:sessions] ||= []
|
|
38
|
+
@doc[:sessions] << session
|
|
39
|
+
save
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def update_session(id, updates)
|
|
43
|
+
session = find_session(id)
|
|
44
|
+
return unless session
|
|
45
|
+
|
|
46
|
+
session.merge!(updates)
|
|
47
|
+
save
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def remove_session(id)
|
|
51
|
+
@doc[:sessions]&.delete_if { |s| s[:id] == id }
|
|
52
|
+
save
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def add_group(group)
|
|
56
|
+
@doc[:groups] ||= []
|
|
57
|
+
@doc[:groups] << group
|
|
58
|
+
save
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def remove_group(id)
|
|
62
|
+
@doc[:groups]&.delete_if { |g| g[:id] == id }
|
|
63
|
+
save
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def save
|
|
67
|
+
tmp = "#{@path}.tmp"
|
|
68
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
69
|
+
File.write(tmp, YAML.dump(@doc))
|
|
70
|
+
File.rename(tmp, @path)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reload
|
|
74
|
+
@doc = load
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def load
|
|
80
|
+
if File.exist?(@path)
|
|
81
|
+
raw = YAML.safe_load(File.read(@path), symbolize_names: true, permitted_classes: [Symbol]) || {}
|
|
82
|
+
Schema.migrate(raw)
|
|
83
|
+
else
|
|
84
|
+
{ version: Schema::SCHEMA_VERSION, groups: [], sessions: [] }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "thread"
|
|
5
|
+
require_relative "router"
|
|
6
|
+
|
|
7
|
+
module NetworkInfraUtility
|
|
8
|
+
module SSH
|
|
9
|
+
module IPC
|
|
10
|
+
# 推送消费端:批量消费 channel.data.batch,按 channel 路由到对应 Terminal。
|
|
11
|
+
# 修正 E9(背压)和 E8(订阅泄漏)。
|
|
12
|
+
class Coalesce
|
|
13
|
+
attr_reader :batch_enabled
|
|
14
|
+
|
|
15
|
+
def initialize(router)
|
|
16
|
+
@router = router
|
|
17
|
+
@terminals = {} # [conn_id, ch_id] => Terminal::Emulator
|
|
18
|
+
@terminals_mutex = Mutex.new
|
|
19
|
+
@batch_enabled = router.server_capabilities&.include?("coalesce") || false
|
|
20
|
+
@sids = {} # [conn_id, ch_id] => subscription_id
|
|
21
|
+
setup_subscriptions
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 注册一个通道到终端的映射
|
|
25
|
+
def register_channel(conn_id, ch_id, terminal)
|
|
26
|
+
key = [conn_id, ch_id]
|
|
27
|
+
@terminals_mutex.synchronize { @terminals[key] = terminal }
|
|
28
|
+
|
|
29
|
+
# 降级模式:逐条 channel.data
|
|
30
|
+
unless @batch_enabled
|
|
31
|
+
sid = @router.subscribe("channel.data") do |p|
|
|
32
|
+
next unless p[:id] == ch_id
|
|
33
|
+
|
|
34
|
+
data = Base64.decode64(p[:data])
|
|
35
|
+
terminal.feed(data)
|
|
36
|
+
end
|
|
37
|
+
@sids[key] = sid
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 注销通道映射,清理订阅
|
|
42
|
+
def unregister_channel(conn_id, ch_id)
|
|
43
|
+
key = [conn_id, ch_id]
|
|
44
|
+
@terminals_mutex.synchronize { @terminals.delete(key) }
|
|
45
|
+
|
|
46
|
+
sid = @sids.delete(key)
|
|
47
|
+
@router.unsubscribe("channel.data", sid) if sid
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# 查找终端(供 batch 分发用)
|
|
51
|
+
def find_terminal(conn_id, ch_id)
|
|
52
|
+
@terminals_mutex.synchronize { @terminals[[conn_id, ch_id]] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def setup_subscriptions
|
|
58
|
+
return unless @batch_enabled
|
|
59
|
+
|
|
60
|
+
@router.subscribe("channel.data.batch") do |p|
|
|
61
|
+
items = p[:items] || []
|
|
62
|
+
items.each do |item|
|
|
63
|
+
# batch 中 item[:id] 是 channel_id,需与已注册的 channel 匹配
|
|
64
|
+
dispatch_batch_item(item[:id], item[:data])
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def dispatch_batch_item(ch_id, b64_data)
|
|
70
|
+
data = Base64.decode64(b64_data)
|
|
71
|
+
@terminals_mutex.synchronize do
|
|
72
|
+
@terminals.each do |_key, terminal|
|
|
73
|
+
next unless terminal.channel_id == ch_id
|
|
74
|
+
|
|
75
|
+
terminal.feed(data)
|
|
76
|
+
break
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NetworkInfraUtility
|
|
4
|
+
module SSH
|
|
5
|
+
module IPC
|
|
6
|
+
# IPC 层异常基类
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# RPC 超时
|
|
10
|
+
class RPCTimeout < Error; end
|
|
11
|
+
|
|
12
|
+
# 远程返回的 JSON-RPC error 对象
|
|
13
|
+
class RPCError < Error
|
|
14
|
+
attr_reader :code, :data
|
|
15
|
+
|
|
16
|
+
def initialize(error_obj)
|
|
17
|
+
@code = error_obj[:code]
|
|
18
|
+
@data = error_obj[:data]
|
|
19
|
+
super(error_obj[:message] || "RPC error code=#{@code}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# 连接尚未就绪时操作通道
|
|
24
|
+
class NotReady < Error; end
|
|
25
|
+
|
|
26
|
+
# 背压暂停时 send 被拒
|
|
27
|
+
class FlowPaused < Error; end
|
|
28
|
+
|
|
29
|
+
# IPC 连接断开
|
|
30
|
+
class ConnectionLost < Error; end
|
|
31
|
+
|
|
32
|
+
# 配置 Schema 版本不匹配
|
|
33
|
+
class SchemaMismatch < Error; end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|