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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +21 -0
  3. data/CHANGELOG.md +31 -4
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +70 -0
  6. data/Rakefile +1 -1
  7. data/bin/dns-query +834 -0
  8. data/bin/geo-doc +135 -0
  9. data/bin/geo-get +1 -1
  10. data/document/ASNum/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +242 -0
  11. data/document/DNSQuery/345/267/245/345/205/267/344/275/277/347/224/250/346/226/271/346/263/225.md +248 -0
  12. 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
  13. data/document/IP/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +297 -0
  14. data/document/MAC/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +296 -0
  15. 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
  16. data/network-infra-utility.gemspec +4 -2
  17. data/network.rb +3 -1
  18. data/service/geodb/GeoAPI.md +1 -0
  19. data/service/geodb/geodb.rb +278 -1
  20. data/service/ssh/README.md +942 -0
  21. data/service/ssh/bin/ssh-client +198 -0
  22. 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
  23. 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
  24. 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
  25. data/service/ssh/ext/ssh_core/bin/ssh_core.cmd +28 -0
  26. data/service/ssh/ext/ssh_core/config/sys.config +0 -0
  27. data/service/ssh/ext/ssh_core/config/vm.args +0 -0
  28. data/service/ssh/ext/ssh_core/local_deps/jsx/CHECKSUM +1 -0
  29. data/service/ssh/ext/ssh_core/local_deps/jsx/LICENSE +21 -0
  30. data/service/ssh/ext/ssh_core/local_deps/jsx/README.md +696 -0
  31. data/service/ssh/ext/ssh_core/local_deps/jsx/VERSION +1 -0
  32. data/service/ssh/ext/ssh_core/local_deps/jsx/contents.tar.gz +0 -0
  33. data/service/ssh/ext/ssh_core/local_deps/jsx/metadata.config +15 -0
  34. data/service/ssh/ext/ssh_core/local_deps/jsx/rebar.config +17 -0
  35. data/service/ssh/ext/ssh_core/local_deps/jsx/rebar.lock +1 -0
  36. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx.app.src +10 -0
  37. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx.erl +506 -0
  38. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_config.erl +393 -0
  39. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_config.hrl +18 -0
  40. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_consult.erl +81 -0
  41. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_decoder.erl +1909 -0
  42. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_encoder.erl +116 -0
  43. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_parser.erl +1214 -0
  44. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_to_json.erl +408 -0
  45. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_to_term.erl +389 -0
  46. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_verify.erl +121 -0
  47. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx.erl +506 -0
  48. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_config.erl +393 -0
  49. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_config.hrl +18 -0
  50. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_consult.erl +81 -0
  51. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_decoder.erl +1909 -0
  52. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_encoder.erl +116 -0
  53. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_parser.erl +1214 -0
  54. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_to_json.erl +408 -0
  55. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_to_term.erl +389 -0
  56. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_verify.erl +121 -0
  57. data/service/ssh/ext/ssh_core/rebar.config +24 -0
  58. data/service/ssh/ext/ssh_core/rebar.lock +1 -0
  59. data/service/ssh/ext/ssh_core/src/ssh_auth_engine.erl +156 -0
  60. data/service/ssh/ext/ssh_core/src/ssh_channel_stm.erl +232 -0
  61. data/service/ssh/ext/ssh_core/src/ssh_codec.erl +83 -0
  62. data/service/ssh/ext/ssh_core/src/ssh_conn_sup.erl +48 -0
  63. data/service/ssh/ext/ssh_core/src/ssh_conn_worker.erl +535 -0
  64. data/service/ssh/ext/ssh_core/src/ssh_core.app.src +36 -0
  65. data/service/ssh/ext/ssh_core/src/ssh_core_app.erl +11 -0
  66. data/service/ssh/ext/ssh_core/src/ssh_core_sup.erl +46 -0
  67. data/service/ssh/ext/ssh_core/src/ssh_infra_sup.erl +104 -0
  68. data/service/ssh/ext/ssh_core/src/ssh_ipc.hrl +80 -0
  69. data/service/ssh/ext/ssh_core/src/ssh_ipc_coalesce.erl +94 -0
  70. data/service/ssh/ext/ssh_core/src/ssh_ipc_gateway.erl +467 -0
  71. data/service/ssh/ext/ssh_core/src/ssh_ipc_proto.erl +95 -0
  72. data/service/ssh/ext/ssh_core/src/ssh_jump_chain.erl +101 -0
  73. data/service/ssh/ext/ssh_core/src/ssh_keepalive_mgr.erl +222 -0
  74. data/service/ssh/ext/ssh_core/src/ssh_known_hosts_proxy.erl +67 -0
  75. data/service/ssh/ext/ssh_core/src/ssh_port_fwd.erl +225 -0
  76. data/service/ssh/ext/ssh_core/src/ssh_sftp_session.erl +250 -0
  77. data/service/ssh/ext/ssh_core/src/ssh_sftp_sup.erl +62 -0
  78. data/service/ssh/ext/ssh_core_rs/Cargo.lock +2345 -0
  79. data/service/ssh/ext/ssh_core_rs/Cargo.toml +30 -0
  80. data/service/ssh/ext/ssh_core_rs/bin/ssh_core_rs +34 -0
  81. data/service/ssh/ext/ssh_core_rs/bin/ssh_core_rs.cmd +40 -0
  82. data/service/ssh/ext/ssh_core_rs/src/channel.rs +296 -0
  83. data/service/ssh/ext/ssh_core_rs/src/coalesce.rs +143 -0
  84. data/service/ssh/ext/ssh_core_rs/src/codec.rs +71 -0
  85. data/service/ssh/ext/ssh_core_rs/src/conn.rs +628 -0
  86. data/service/ssh/ext/ssh_core_rs/src/gateway.rs +389 -0
  87. data/service/ssh/ext/ssh_core_rs/src/handler.rs +293 -0
  88. data/service/ssh/ext/ssh_core_rs/src/keepalive.rs +194 -0
  89. data/service/ssh/ext/ssh_core_rs/src/main.rs +351 -0
  90. data/service/ssh/ext/ssh_core_rs/src/portfwd.rs +378 -0
  91. data/service/ssh/ext/ssh_core_rs/src/proto.rs +198 -0
  92. data/service/ssh/ext/ssh_core_rs/src/sftp.rs +294 -0
  93. data/service/ssh/lib/network_infra_utility/ssh/automation/macro_engine.rb +213 -0
  94. data/service/ssh/lib/network_infra_utility/ssh/client.rb +257 -0
  95. data/service/ssh/lib/network_infra_utility/ssh/config/schema.rb +90 -0
  96. data/service/ssh/lib/network_infra_utility/ssh/config/settings.rb +103 -0
  97. data/service/ssh/lib/network_infra_utility/ssh/config/store.rb +90 -0
  98. data/service/ssh/lib/network_infra_utility/ssh/ipc/coalesce.rb +83 -0
  99. data/service/ssh/lib/network_infra_utility/ssh/ipc/errors.rb +36 -0
  100. data/service/ssh/lib/network_infra_utility/ssh/ipc/router.rb +212 -0
  101. data/service/ssh/lib/network_infra_utility/ssh/ipc/transport.rb +81 -0
  102. data/service/ssh/lib/network_infra_utility/ssh/security/host_key.rb +211 -0
  103. data/service/ssh/lib/network_infra_utility/ssh/security/vault.rb +211 -0
  104. data/service/ssh/lib/network_infra_utility/ssh/session/history.rb +56 -0
  105. data/service/ssh/lib/network_infra_utility/ssh/session/manager.rb +92 -0
  106. data/service/ssh/lib/network_infra_utility/ssh/session/session.rb +109 -0
  107. data/service/ssh/lib/network_infra_utility/ssh/session/tree.rb +95 -0
  108. data/service/ssh/lib/network_infra_utility/ssh/terminal/ansi_parser.rb +435 -0
  109. data/service/ssh/lib/network_infra_utility/ssh/terminal/buffer.rb +78 -0
  110. data/service/ssh/lib/network_infra_utility/ssh/terminal/emulator.rb +159 -0
  111. data/service/ssh/lib/network_infra_utility/ssh/terminal/logger.rb +195 -0
  112. data/service/ssh/lib/network_infra_utility/ssh/terminal/screen.rb +212 -0
  113. data/service/ssh/lib/network_infra_utility/ssh/terminal/theme.rb +127 -0
  114. data/service/ssh/lib/network_infra_utility/ssh/version.rb +7 -0
  115. data/service/ssh/lib/network_infra_utility/ssh.rb +44 -0
  116. data/support/basic/as_num.rb +221 -0
  117. data/support/basic/mac_address.rb +281 -0
  118. data/version.rb +1 -1
  119. metadata +138 -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