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,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "thread"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "transport"
|
|
8
|
+
|
|
9
|
+
module NetworkInfraUtility
|
|
10
|
+
module SSH
|
|
11
|
+
module IPC
|
|
12
|
+
# JSON-RPC 2.0 路由器:请求-响应配对、推送分发、订阅注册/注销。
|
|
13
|
+
# 取代 HLD 的 IPC::Client,修正 E8(订阅泄漏)。
|
|
14
|
+
class Router
|
|
15
|
+
PROTOCOL_VER = "1.0"
|
|
16
|
+
|
|
17
|
+
attr_reader :transport, :server_capabilities
|
|
18
|
+
|
|
19
|
+
def initialize(transport)
|
|
20
|
+
@transport = transport
|
|
21
|
+
@id_mutex = Mutex.new
|
|
22
|
+
@next_id = 0
|
|
23
|
+
@pending = {} # id => [Queue, deadline]
|
|
24
|
+
@pending_mutex = Mutex.new
|
|
25
|
+
@subscriptions = {} # method => { sid => Proc }
|
|
26
|
+
@subscriptions_mutex = Mutex.new
|
|
27
|
+
@reverse_handlers = {} # method => Proc (Erlang→Ruby 带 id 的反向 RPC)
|
|
28
|
+
@reverse_mutex = Mutex.new
|
|
29
|
+
@closed = false
|
|
30
|
+
@disconnect_callbacks = []
|
|
31
|
+
@disconnect_mutex = Mutex.new
|
|
32
|
+
@reader_thread = nil
|
|
33
|
+
@server_capabilities = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 连接到引擎并完成 hello 握手
|
|
37
|
+
def connect(endpoint, auth_token:, client_id:)
|
|
38
|
+
@transport.connect(endpoint)
|
|
39
|
+
start_reader_thread
|
|
40
|
+
resp = call("hello", {
|
|
41
|
+
auth_token: auth_token,
|
|
42
|
+
ver: PROTOCOL_VER,
|
|
43
|
+
client_id: client_id
|
|
44
|
+
}, timeout_ms: 10_000)
|
|
45
|
+
@server_capabilities = resp[:capabilities] || []
|
|
46
|
+
resp
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# 注册连接断开回调
|
|
50
|
+
# 回调签名: ->(reason) { }
|
|
51
|
+
def on_disconnect(&block)
|
|
52
|
+
@disconnect_mutex.synchronize do
|
|
53
|
+
@disconnect_callbacks << block
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# 同步 RPC 调用
|
|
58
|
+
def call(method, params = {}, timeout_ms: 30_000)
|
|
59
|
+
ensure_open
|
|
60
|
+
id = next_id
|
|
61
|
+
q = Queue.new
|
|
62
|
+
register_pending(id, q, timeout_ms)
|
|
63
|
+
@transport.send(jsonrpc: "2.0", id: id, method: method, params: params)
|
|
64
|
+
msg = q.pop(timeout: timeout_ms / 1000.0)
|
|
65
|
+
raise RPCTimeout, method unless msg
|
|
66
|
+
raise RPCError.new(msg[:error]) if msg[:error]
|
|
67
|
+
|
|
68
|
+
msg[:result]
|
|
69
|
+
ensure
|
|
70
|
+
unregister_pending(id) if id
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# 订阅推送,返回 subscription_id 用于注销(修正 E8)
|
|
74
|
+
def subscribe(method, &block)
|
|
75
|
+
sid = SecureRandom.hex(8)
|
|
76
|
+
@subscriptions_mutex.synchronize do
|
|
77
|
+
(@subscriptions[method] ||= {})[sid] = block
|
|
78
|
+
end
|
|
79
|
+
sid
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 注销订阅
|
|
83
|
+
def unsubscribe(method, sid)
|
|
84
|
+
@subscriptions_mutex.synchronize do
|
|
85
|
+
@subscriptions[method]&.delete(sid)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# 注册反向 RPC 处理器(Erlang→Ruby 带 id 的请求,如 hostkey.resolve)
|
|
90
|
+
def on_reverse_rpc(method, &block)
|
|
91
|
+
@reverse_mutex.synchronize do
|
|
92
|
+
@reverse_handlers[method] = block
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# 回复反向 RPC(给 Erlang 发 result)
|
|
97
|
+
def reply_reverse(id, result)
|
|
98
|
+
@transport.send(jsonrpc: "2.0", id: id, result: result)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def close
|
|
102
|
+
@closed = true
|
|
103
|
+
@reader_thread&.kill
|
|
104
|
+
@transport.close
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def closed?
|
|
108
|
+
@closed
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def ensure_open
|
|
114
|
+
raise ConnectionLost, "Router closed" if @closed
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def start_reader_thread
|
|
118
|
+
@reader_thread = Thread.new do
|
|
119
|
+
@transport.each_frame do |frame|
|
|
120
|
+
msg = JSON.parse(frame, symbolize_names: true)
|
|
121
|
+
on_message(msg)
|
|
122
|
+
end
|
|
123
|
+
# EOF reached — 正常关闭
|
|
124
|
+
handle_disconnect("connection closed (EOF)")
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
handle_disconnect("reader thread error: #{e.message}")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# 引擎断连时调用:通知所有回调并清空 pending 队列,
|
|
131
|
+
# 避免调用方阻塞至 30s 超时(修正 reader thread 异常不通知 @pending 的缺陷)
|
|
132
|
+
def handle_disconnect(reason)
|
|
133
|
+
@closed = true
|
|
134
|
+
|
|
135
|
+
# 1. 通知所有断连回调
|
|
136
|
+
callbacks = @disconnect_mutex.synchronize do
|
|
137
|
+
@disconnect_callbacks.dup
|
|
138
|
+
end
|
|
139
|
+
callbacks.each { |cb| cb.call(reason) }
|
|
140
|
+
|
|
141
|
+
# 2. 清空 @pending,给每个等待的 Queue 推入 nil(触发 RPCTimeout)
|
|
142
|
+
pending = @pending_mutex.synchronize do
|
|
143
|
+
old = @pending
|
|
144
|
+
@pending = {}
|
|
145
|
+
old
|
|
146
|
+
end
|
|
147
|
+
pending.each_value { |queue, _deadline| queue << nil }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# 由 reader thread 调用
|
|
151
|
+
def on_message(msg)
|
|
152
|
+
if msg[:id]
|
|
153
|
+
if msg[:method]
|
|
154
|
+
# Erlang→Ruby 的反向 RPC(带 id 和 method)
|
|
155
|
+
handle_reverse_rpc(msg)
|
|
156
|
+
else
|
|
157
|
+
# 对我方请求的响应
|
|
158
|
+
dispatch_response(msg)
|
|
159
|
+
end
|
|
160
|
+
elsif msg[:method]
|
|
161
|
+
# 推送 notification
|
|
162
|
+
dispatch_push(msg[:method], msg[:params])
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def dispatch_response(msg)
|
|
167
|
+
entry = @pending_mutex.synchronize { @pending.delete(msg[:id]) }
|
|
168
|
+
return unless entry
|
|
169
|
+
|
|
170
|
+
queue, _deadline = entry
|
|
171
|
+
queue << msg
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def dispatch_push(method, params)
|
|
175
|
+
callbacks = @subscriptions_mutex.synchronize do
|
|
176
|
+
@subscriptions[method]&.values&.dup
|
|
177
|
+
end
|
|
178
|
+
callbacks&.each { |cb| cb.call(params) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def handle_reverse_rpc(msg)
|
|
182
|
+
handler = @reverse_mutex.synchronize { @reverse_handlers[msg[:method]] }
|
|
183
|
+
if handler
|
|
184
|
+
begin
|
|
185
|
+
result = handler.call(msg[:params])
|
|
186
|
+
reply_reverse(msg[:id], result)
|
|
187
|
+
rescue StandardError => e
|
|
188
|
+
@transport.send(jsonrpc: "2.0", id: msg[:id],
|
|
189
|
+
error: { code: -32603, message: e.message })
|
|
190
|
+
end
|
|
191
|
+
else
|
|
192
|
+
@transport.send(jsonrpc: "2.0", id: msg[:id],
|
|
193
|
+
error: { code: -32601, message: "No handler for #{msg[:method]}" })
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def next_id
|
|
198
|
+
@id_mutex.synchronize { @next_id += 1 }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def register_pending(id, queue, timeout_ms)
|
|
202
|
+
deadline = Time.now + timeout_ms / 1000.0
|
|
203
|
+
@pending_mutex.synchronize { @pending[id] = [queue, deadline] }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def unregister_pending(id)
|
|
207
|
+
@pending_mutex.synchronize { @pending.delete(id) }
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module NetworkInfraUtility
|
|
9
|
+
module SSH
|
|
10
|
+
module IPC
|
|
11
|
+
# socket 读写与分帧。重连为 V2.0 功能。
|
|
12
|
+
# 纯 I/O 层,不做业务路由。
|
|
13
|
+
class Transport
|
|
14
|
+
attr_reader :endpoint, :connected
|
|
15
|
+
|
|
16
|
+
def initialize(endpoint = nil)
|
|
17
|
+
@endpoint = endpoint
|
|
18
|
+
@socket = nil
|
|
19
|
+
@write_mutex = Mutex.new
|
|
20
|
+
@connected = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# 连接到端点(unix:path 或 tcp://host:port)
|
|
24
|
+
def connect(endpoint = @endpoint)
|
|
25
|
+
raise ArgumentError, "No endpoint" unless endpoint
|
|
26
|
+
|
|
27
|
+
@endpoint = endpoint
|
|
28
|
+
@socket = open_socket(endpoint)
|
|
29
|
+
@connected = true
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# 在 reader thread 中循环调用,按 \n 分帧
|
|
34
|
+
def each_frame
|
|
35
|
+
raise ConnectionLost, "Not connected" unless @connected
|
|
36
|
+
|
|
37
|
+
buffer = +""
|
|
38
|
+
loop do
|
|
39
|
+
chunk = @socket.readpartial(65536)
|
|
40
|
+
buffer << chunk
|
|
41
|
+
while (idx = buffer.index("\n"))
|
|
42
|
+
yield buffer[0...idx]
|
|
43
|
+
buffer = buffer[(idx + 1)..]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE
|
|
47
|
+
@connected = false
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# 发送一条 JSON 消息(自动追加 \n)
|
|
52
|
+
def send(hash)
|
|
53
|
+
raise ConnectionLost, "Not connected" unless @connected
|
|
54
|
+
|
|
55
|
+
data = JSON.generate(hash) + "\n"
|
|
56
|
+
@write_mutex.synchronize { @socket.write(data) }
|
|
57
|
+
end
|
|
58
|
+
alias << send
|
|
59
|
+
|
|
60
|
+
def close
|
|
61
|
+
@connected = false
|
|
62
|
+
@socket&.close
|
|
63
|
+
@socket = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def open_socket(endpoint)
|
|
69
|
+
case endpoint
|
|
70
|
+
when /\Aunix:(.+)/
|
|
71
|
+
UNIXSocket.new(Regexp.last_match(1))
|
|
72
|
+
when /\Atcp:\/\/(.+):(\d+)/
|
|
73
|
+
TCPSocket.new(Regexp.last_match(1), Regexp.last_match(2).to_i)
|
|
74
|
+
else
|
|
75
|
+
raise ArgumentError, "Unknown endpoint format: #{endpoint}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "thread"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require "time"
|
|
9
|
+
|
|
10
|
+
module NetworkInfraUtility
|
|
11
|
+
module SSH
|
|
12
|
+
module Security
|
|
13
|
+
# known_hosts 落盘 + 主机密钥裁决。
|
|
14
|
+
# 对应需求 FR-SEC-002。
|
|
15
|
+
#
|
|
16
|
+
# LLD E7 勘误修正:
|
|
17
|
+
# Erlang 负责校验(OTP ssh 的 key_cb),
|
|
18
|
+
# 持久化由 Ruby 经 IPC 提供,Erlang 不落盘 known_hosts。
|
|
19
|
+
#
|
|
20
|
+
# 三条裁决路径(LLD §7.8):
|
|
21
|
+
# 1. 未知主机 → prompt_user → accept 则存盘且返回 accept;reject 不存盘
|
|
22
|
+
# 2. 已存在且指纹匹配 → 直接 accept(无交互)
|
|
23
|
+
# 3. 已存在但指纹变更 → warn_host_key_changed + reject(无交互)
|
|
24
|
+
#
|
|
25
|
+
# IPC 双向变体:
|
|
26
|
+
# hostkey.resolve 是 Erlang→Ruby 的同步请求(带 id),
|
|
27
|
+
# Ruby 必须回一个同 id 的 result。
|
|
28
|
+
# Erlang 侧 30s 超时后自动 reject。
|
|
29
|
+
class HostKey
|
|
30
|
+
# known_hosts.yml 记录格式:
|
|
31
|
+
# <host>:<port>:
|
|
32
|
+
# fingerprint: <SHA-256 base64>
|
|
33
|
+
# added_at: <ISO8601>
|
|
34
|
+
|
|
35
|
+
RESOLVE_TIMEOUT = 30 # 秒,与 Erlang 侧一致
|
|
36
|
+
|
|
37
|
+
attr_reader :store_path, :entries
|
|
38
|
+
|
|
39
|
+
# @param store_path [String] known_hosts.yml 路径
|
|
40
|
+
def initialize(store_path)
|
|
41
|
+
@store_path = store_path
|
|
42
|
+
@entries = load
|
|
43
|
+
@save_mutex = Mutex.new
|
|
44
|
+
@prompt_callback = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# 设置用户交互回调(V1.0 默认 STDIN,V2.0 TUI 弹窗)
|
|
48
|
+
# 回调签名: ->(host, port, fingerprint) { :accept | :reject | :once }
|
|
49
|
+
# 注意::once 仅由回调模式返回,CLI 默认模式(prompt_user)不支持 :once
|
|
50
|
+
def on_prompt(&block)
|
|
51
|
+
@prompt_callback = block
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# 设置主机密钥变更告警回调
|
|
55
|
+
# 回调签名: ->(host, port, old_fp, new_fp) { }
|
|
56
|
+
def on_key_changed(&block)
|
|
57
|
+
@changed_callback = block
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# 由 IPC 推送 hostkey.resolve 触发(LLD §7.8)
|
|
61
|
+
# @param host [String]
|
|
62
|
+
# @param port [Integer]
|
|
63
|
+
# @param fingerprint [String] SHA-256 base64 指纹
|
|
64
|
+
# @return [Hash] { action: "accept" | "reject" | "once" }
|
|
65
|
+
def resolve(host, port, fingerprint)
|
|
66
|
+
key = entry_key(host, port)
|
|
67
|
+
|
|
68
|
+
if @entries[key].nil?
|
|
69
|
+
# 路径1:未知主机
|
|
70
|
+
action = prompt_user(host, port, fingerprint)
|
|
71
|
+
if action == :accept || action == :once
|
|
72
|
+
@save_mutex.synchronize do
|
|
73
|
+
@entries[key] = {
|
|
74
|
+
"fingerprint" => fingerprint,
|
|
75
|
+
"added_at" => Time.now.iso8601
|
|
76
|
+
}
|
|
77
|
+
save
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
{ action: action.to_s }
|
|
81
|
+
elsif @entries[key]["fingerprint"] == fingerprint
|
|
82
|
+
# 路径2:指纹匹配
|
|
83
|
+
{ action: "accept" }
|
|
84
|
+
else
|
|
85
|
+
# 路径3:指纹变更
|
|
86
|
+
warn_host_key_changed(host, port, @entries[key]["fingerprint"], fingerprint)
|
|
87
|
+
{ action: "reject" }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# 手动添加信任条目
|
|
92
|
+
# @param host [String]
|
|
93
|
+
# @param port [Integer]
|
|
94
|
+
# @param fingerprint [String]
|
|
95
|
+
def add(host, port, fingerprint)
|
|
96
|
+
key = entry_key(host, port)
|
|
97
|
+
@save_mutex.synchronize do
|
|
98
|
+
@entries[key] = {
|
|
99
|
+
"fingerprint" => fingerprint,
|
|
100
|
+
"added_at" => Time.now.iso8601
|
|
101
|
+
}
|
|
102
|
+
save
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# 删除信任条目
|
|
107
|
+
# @param host [String]
|
|
108
|
+
# @param port [Integer]
|
|
109
|
+
def remove(host, port)
|
|
110
|
+
key = entry_key(host, port)
|
|
111
|
+
@save_mutex.synchronize do
|
|
112
|
+
@entries.delete(key)
|
|
113
|
+
save
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# 查询条目
|
|
118
|
+
# @param host [String]
|
|
119
|
+
# @param port [Integer]
|
|
120
|
+
# @return [Hash, nil]
|
|
121
|
+
def get(host, port)
|
|
122
|
+
@entries[entry_key(host, port)]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# 列出全部条目
|
|
126
|
+
# @return [Array<Hash>]
|
|
127
|
+
def list
|
|
128
|
+
@entries.map do |key, val|
|
|
129
|
+
host, port = parse_entry_key(key)
|
|
130
|
+
{ host: host, port: port, **val }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
# 生成 known_hosts 条目 key
|
|
137
|
+
def entry_key(host, port)
|
|
138
|
+
"#{host}:#{port}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def parse_entry_key(key)
|
|
142
|
+
# 从右侧按第一个冒号分割(host:port)
|
|
143
|
+
# IPv6 地址含多个冒号,但我们暂不支持 IPv6
|
|
144
|
+
idx = key.rindex(":")
|
|
145
|
+
if idx
|
|
146
|
+
host = key[0...idx]
|
|
147
|
+
port = key[(idx + 1)..].to_i
|
|
148
|
+
port = 22 if port == 0
|
|
149
|
+
[host, port]
|
|
150
|
+
else
|
|
151
|
+
[key, 22]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# V1.0 CLI:通过 STDIN 询问用户;V2.0 TUI 弹窗,超时 30s 默认 reject
|
|
156
|
+
def prompt_user(host, port, fingerprint)
|
|
157
|
+
if @prompt_callback
|
|
158
|
+
result = @prompt_callback.call(host, port, fingerprint)
|
|
159
|
+
return result == :accept ? :accept : (result == :once ? :once : :reject)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# 默认 CLI 交互
|
|
163
|
+
puts "首次连接 #{host}:#{port},指纹 #{fingerprint},是否信任?(y/N)"
|
|
164
|
+
line = STDIN.gets&.chomp
|
|
165
|
+
line =~ /^y/i ? :accept : :reject
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def warn_host_key_changed(host, port, old_fp, new_fp)
|
|
169
|
+
if @changed_callback
|
|
170
|
+
@changed_callback.call(host, port, old_fp, new_fp)
|
|
171
|
+
else
|
|
172
|
+
warn "警告:主机 #{host}:#{port} 的密钥指纹已变更!"
|
|
173
|
+
warn " 旧指纹: #{old_fp}"
|
|
174
|
+
warn " 新指纹: #{new_fp}"
|
|
175
|
+
warn " 连接已被拒绝。如确认安全,请手动删除 known_hosts 条目后重连。"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def load
|
|
180
|
+
return {} unless File.exist?(@store_path)
|
|
181
|
+
|
|
182
|
+
# 使用字符串 key 而非 Symbol:safe_load 不允许 Symbol,
|
|
183
|
+
# 消除 permitted_classes: [Symbol] 带来的反序列化攻击面。
|
|
184
|
+
# 写入端 save 也已改为字符串 key。
|
|
185
|
+
YAML.safe_load(File.read(@store_path)) || {}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def save
|
|
189
|
+
FileUtils.mkdir_p(File.dirname(@store_path))
|
|
190
|
+
tmp = "#{@store_path}.tmp"
|
|
191
|
+
File.write(tmp, YAML.dump(@entries))
|
|
192
|
+
File.rename(tmp, @store_path)
|
|
193
|
+
set_file_permissions
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def set_file_permissions
|
|
197
|
+
if Gem.win_platform?
|
|
198
|
+
user = ENV["USERNAME"] || ENV["USER"] || ""
|
|
199
|
+
# 白名单校验用户名,防止 icacls 命令注入
|
|
200
|
+
unless user.match(/\A[A-Za-z0-9._-]+\z/)
|
|
201
|
+
raise SecurityError, "Invalid username for ACL: #{user.inspect}"
|
|
202
|
+
end
|
|
203
|
+
system("icacls", @store_path, "/inheritance:r", "/grant:r", "#{user}:F")
|
|
204
|
+
else
|
|
205
|
+
File.chmod(0o600, @store_path)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|