net-connector 0.4.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 (109) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +82 -0
  3. data/LICENSE +21 -0
  4. data/README.md +277 -0
  5. data/docs/RELEASING.md +81 -0
  6. data/docs/VERIFICATION.md +73 -0
  7. data/docs/architecture.md +268 -0
  8. data/exe/net-connector-backup +6 -0
  9. data/lib/net/connector/device/base.rb +368 -0
  10. data/lib/net/connector/device/interface_description.rb +41 -0
  11. data/lib/net/connector/device/interface_name.rb +56 -0
  12. data/lib/net/connector/device/profile.rb +446 -0
  13. data/lib/net/connector/device/running_config/strategy.rb +29 -0
  14. data/lib/net/connector/device/running_config.rb +69 -0
  15. data/lib/net/connector/engine/authentication.rb +77 -0
  16. data/lib/net/connector/engine/base.rb +4 -0
  17. data/lib/net/connector/engine/command.rb +101 -0
  18. data/lib/net/connector/engine/configuration.rb +131 -0
  19. data/lib/net/connector/engine/core.rb +6 -0
  20. data/lib/net/connector/engine/dialogue.rb +223 -0
  21. data/lib/net/connector/engine/errors.rb +183 -0
  22. data/lib/net/connector/engine/execution.rb +64 -0
  23. data/lib/net/connector/engine/log.rb +227 -0
  24. data/lib/net/connector/engine/log_messages.rb +47 -0
  25. data/lib/net/connector/engine/profile.rb +4 -0
  26. data/lib/net/connector/engine/recovery.rb +57 -0
  27. data/lib/net/connector/engine/result.rb +67 -0
  28. data/lib/net/connector/engine/session.rb +333 -0
  29. data/lib/net/connector/engine/terminal_renderer.rb +158 -0
  30. data/lib/net/connector/engine/transport.rb +184 -0
  31. data/lib/net/connector/engine.rb +4 -0
  32. data/lib/net/connector/netdisco/batch.rb +71 -0
  33. data/lib/net/connector/netdisco/cli.rb +142 -0
  34. data/lib/net/connector/netdisco/client.rb +148 -0
  35. data/lib/net/connector/netdisco/config_file.rb +87 -0
  36. data/lib/net/connector/netdisco/device.rb +75 -0
  37. data/lib/net/connector/netdisco/fleet.rb +258 -0
  38. data/lib/net/connector/netdisco/planner.rb +77 -0
  39. data/lib/net/connector/netdisco/result_store.rb +43 -0
  40. data/lib/net/connector/netdisco/rules.rb +131 -0
  41. data/lib/net/connector/netdisco/settings.rb +183 -0
  42. data/lib/net/connector/netdisco/worker.rb +75 -0
  43. data/lib/net/connector/netdisco.rb +14 -0
  44. data/lib/net/connector/operations/local_backup.rb +49 -0
  45. data/lib/net/connector/operations/parse_output.rb +60 -0
  46. data/lib/net/connector/operations/private_file.rb +25 -0
  47. data/lib/net/connector/operations/running_config/cisco.rb +14 -0
  48. data/lib/net/connector/operations/running_config/hillstone.rb +12 -0
  49. data/lib/net/connector/operations/running_config/palo_alto.rb +12 -0
  50. data/lib/net/connector/operations/running_config/strategy.rb +3 -0
  51. data/lib/net/connector/operations/running_config.rb +16 -0
  52. data/lib/net/connector/operations/saved_config.rb +72 -0
  53. data/lib/net/connector/operations/tftp/cisco_ios.rb +11 -0
  54. data/lib/net/connector/operations/tftp/cisco_nxos.rb +11 -0
  55. data/lib/net/connector/operations/tftp/h3c.rb +11 -0
  56. data/lib/net/connector/operations/tftp/hillstone.rb +11 -0
  57. data/lib/net/connector/operations/tftp/huawei.rb +11 -0
  58. data/lib/net/connector/operations/tftp/palo_alto.rb +11 -0
  59. data/lib/net/connector/operations/tftp/radware.rb +11 -0
  60. data/lib/net/connector/operations/tftp/strategy.rb +77 -0
  61. data/lib/net/connector/operations/tftp_backup.rb +120 -0
  62. data/lib/net/connector/operations/topology/cisco.rb +11 -0
  63. data/lib/net/connector/operations/topology/h3c.rb +11 -0
  64. data/lib/net/connector/operations/topology/hillstone.rb +11 -0
  65. data/lib/net/connector/operations/topology/palo_alto.rb +11 -0
  66. data/lib/net/connector/operations/topology/radware.rb +11 -0
  67. data/lib/net/connector/operations/topology/strategy.rb +53 -0
  68. data/lib/net/connector/operations/topology.rb +192 -0
  69. data/lib/net/connector/operations.rb +20 -0
  70. data/lib/net/connector/templates/cisco_cdp_neighbors_detail.textfsm +7 -0
  71. data/lib/net/connector/templates/cisco_ios_running_config_interfaces.textfsm +12 -0
  72. data/lib/net/connector/templates/cisco_ios_show_ip_interface_brief.textfsm +7 -0
  73. data/lib/net/connector/templates/h3c_interface_descriptions.textfsm +11 -0
  74. data/lib/net/connector/templates/h3c_lldp_local_first.textfsm +8 -0
  75. data/lib/net/connector/templates/h3c_lldp_name_first.textfsm +8 -0
  76. data/lib/net/connector/templates/hillstone_interface_descriptions.textfsm +11 -0
  77. data/lib/net/connector/templates/hillstone_lldp_neighbors.textfsm +7 -0
  78. data/lib/net/connector/templates/index +8 -0
  79. data/lib/net/connector/templates/palo_alto_interface_descriptions.textfsm +6 -0
  80. data/lib/net/connector/templates/palo_alto_lldp_neighbors.textfsm +11 -0
  81. data/lib/net/connector/templates/radware_port_names.textfsm +13 -0
  82. data/lib/net/connector/vendor/cisco_ios/running_config.rb +19 -0
  83. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +32 -0
  84. data/lib/net/connector/vendor/cisco_ios/topology.rb +33 -0
  85. data/lib/net/connector/vendor/cisco_ios.rb +48 -0
  86. data/lib/net/connector/vendor/cisco_nxos/running_config.rb +22 -0
  87. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +34 -0
  88. data/lib/net/connector/vendor/cisco_nxos.rb +55 -0
  89. data/lib/net/connector/vendor/h3c/tftp_backup.rb +43 -0
  90. data/lib/net/connector/vendor/h3c/topology.rb +56 -0
  91. data/lib/net/connector/vendor/h3c.rb +77 -0
  92. data/lib/net/connector/vendor/h3c_wireless.rb +14 -0
  93. data/lib/net/connector/vendor/hillstone/running_config.rb +13 -0
  94. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +40 -0
  95. data/lib/net/connector/vendor/hillstone/topology.rb +48 -0
  96. data/lib/net/connector/vendor/hillstone.rb +43 -0
  97. data/lib/net/connector/vendor/huawei/tftp_backup.rb +34 -0
  98. data/lib/net/connector/vendor/huawei.rb +64 -0
  99. data/lib/net/connector/vendor/palo_alto/running_config.rb +70 -0
  100. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +36 -0
  101. data/lib/net/connector/vendor/palo_alto/topology.rb +63 -0
  102. data/lib/net/connector/vendor/palo_alto.rb +43 -0
  103. data/lib/net/connector/vendor/radware/tftp_backup.rb +40 -0
  104. data/lib/net/connector/vendor/radware/topology.rb +14 -0
  105. data/lib/net/connector/vendor/radware.rb +62 -0
  106. data/lib/net/connector/version.rb +7 -0
  107. data/lib/net/connector.rb +48 -0
  108. data/lib/net-connector.rb +3 -0
  109. metadata +517 -0
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require_relative "authentication"
5
+ require_relative "log"
6
+ require_relative "recovery"
7
+ require_relative "transport"
8
+
9
+ module Net
10
+ module Connector
11
+ # 连接状态、传输层和日志的唯一所有者;同一时刻只允许一个操作占用会话。
12
+ class Session
13
+ attr_reader :configuration, :transport, :reader, :redactor, :state, :prompt
14
+
15
+ # 创建认证、响应读取、脱敏和日志对象,并把会话置于关闭状态。
16
+ def initialize(configuration:, dialogue:, transport:, recovery:, after_login:)
17
+ @configuration = configuration
18
+ @dialogue = dialogue
19
+ @transport = transport
20
+ @recovery = recovery
21
+ @after_login = after_login
22
+ @redactor = Redactor.new(configuration.password, configuration.enable_password)
23
+ @reader = ResponseReader.new(self)
24
+ @authentication = Authentication.new(self, @dialogue)
25
+ @log = Log.new(configuration, redactor: redactor)
26
+ @state = :closed
27
+ @privileged = false
28
+ @lock = Mutex.new
29
+ end
30
+
31
+ # 判断会话状态和传输层是否都表明连接仍然有效。
32
+ def connected? = state != :closed && !transport.closed?
33
+
34
+ # 返回当前是否已经进入特权模式。
35
+ def privileged? = @privileged
36
+
37
+ # 标记当前会话已进入特权模式。
38
+ def mark_privileged! = @privileged = true
39
+
40
+ # 记录脚本之外的业务结果,例如设备报告的 TFTP 上传状态。
41
+ def log_event(name, **fields) = @log.event(name, **fields)
42
+
43
+ # 在会话锁内建立连接。
44
+ def connect
45
+ perform(:connect) { self }
46
+ end
47
+
48
+ # 锁覆盖完整操作而不是单次写入,保证批处理命令不会交错。
49
+ def perform(phase)
50
+ if @operation_owner == [Thread.current, Fiber.current] && !@performing
51
+ return perform_locked(phase) { yield }
52
+ end
53
+ unless @lock.try_lock
54
+ raise error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
55
+ end
56
+
57
+ begin
58
+ perform_locked(phase) { yield }
59
+ ensure
60
+ @lock.unlock
61
+ end
62
+ end
63
+
64
+ # 业务操作在多次脚本之间持有租约;只有同一线程和 Fiber 可顺序使用。
65
+ def with_operation(phase)
66
+ unless @lock.try_lock
67
+ raise error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
68
+ end
69
+ @operation_owner = [Thread.current, Fiber.current]
70
+ begin
71
+ yield
72
+ ensure
73
+ @operation_owner = nil
74
+ @lock.unlock
75
+ end
76
+ end
77
+
78
+ def perform_locked(phase)
79
+ @performing = true
80
+ completed = false
81
+ begin
82
+ unless connected?
83
+ close_resources unless state == :closed
84
+ connect_session
85
+ end
86
+ @state = (phase == :connect) ? :ready : :executing
87
+ result = yield
88
+ completed = true
89
+ result
90
+ rescue => error
91
+ failure = normalize_error(error, phase: phase)
92
+ close_preserving_failure
93
+ raise failure, cause: nil
94
+ rescue Exception # rubocop:disable Lint/RescueException -- Interrupts release the transport, then propagate.
95
+ close_preserving_failure
96
+ raise
97
+ ensure
98
+ # throw/catch 也会离开此块;对话未完成时必须关闭连接,不能把
99
+ # 半途的设备提示符误当成下一条命令的响应。
100
+ close_preserving_failure unless completed || !connected?
101
+ @state = :ready if connected?
102
+ @performing = false
103
+ end
104
+ end
105
+
106
+ private :perform_locked
107
+
108
+ # 独占关闭会话;已有操作占用时返回会话繁忙错误。
109
+ def close
110
+ unless @lock.try_lock
111
+ raise error(SessionBusy, "cannot close a session owned by another operation", phase: :close), cause: nil
112
+ end
113
+
114
+ begin
115
+ close_resources
116
+ ensure
117
+ @lock.unlock
118
+ end
119
+ end
120
+
121
+ # 执行特权认证,并更新提示符及特权状态。
122
+ def enable(command, prompt)
123
+ unless command
124
+ raise error(UnsupportedOperation, "privilege authentication is not supported", phase: :enable), cause: nil
125
+ end
126
+
127
+ @privileged = false
128
+ response = @log.pause { @authentication.enable(command, prompt) }
129
+ @prompt = response.prompt
130
+ @privileged = true
131
+ response
132
+ end
133
+
134
+ # 命令准备、交换、后处理及回调共用一份临时词表,退出时一并清除。
135
+ def command_scope(command)
136
+ redactor.scope do
137
+ protect_command(command)
138
+ yield
139
+ end
140
+ end
141
+
142
+ # 复用完整命令的脱敏范围;厂商后续查询产生的秘密保留到外层回调结束。
143
+ def exchange(command, timeout:, prompt: nil)
144
+ redactor.scope(reuse: true) do
145
+ protect_command(command)
146
+ started = Expect.monotonic
147
+ @log.event("command_start", text: command.sensitive? ? "[REDACTED]" : command.text)
148
+ sensitive_dialogue = [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
149
+ @log.event("device_output", level: :debug) if @log.detailed? && !command.sensitive? && !sensitive_dialogue
150
+ response = exchange_command(command, timeout: timeout, prompt: prompt)
151
+ @log.response_output(response.raw) unless command.sensitive? || sensitive_dialogue
152
+ @log.event("command_complete", status: "response_received")
153
+ @log.event("command_detail", level: :debug,
154
+ duration_ms: ((Expect.monotonic - started) * 1000).round,
155
+ response_bytes: response.raw.bytesize)
156
+ response
157
+ rescue => error
158
+ failure = normalize_error(error, phase: :command, command: command)
159
+ @log.event("command_complete", level: :error, status: "failed",
160
+ error: failure.class.name, message: failure.message)
161
+ raise failure, cause: nil
162
+ end
163
+ end
164
+
165
+ # 在用户钩子运行前标记敏感上下文,动态交互尚未返回时也能保护其异常。
166
+ def protect_command(command)
167
+ if command.sensitive? || [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
168
+ redactor.sensitive!
169
+ end
170
+ redactor.remember(command.text) if command.sensitive?
171
+ end
172
+
173
+ private :protect_command
174
+
175
+ # 写入命令、读取对话、识别设备诊断,并返回响应对象。
176
+ def exchange_command(command, timeout:, prompt: nil)
177
+ deadline = Expect.monotonic + (command.timeout || timeout)
178
+ interactions = [*command.interactions, *@dialogue.command_interactions]
179
+ operation = lambda do
180
+ write("#{command.text}\n", deadline: deadline, phase: :command, command: command)
181
+ response = reader.read(prompt: command.prompt || prompt || @dialogue.command_prompt,
182
+ interactions: interactions,
183
+ deadline: deadline, phase: :command, command: command)
184
+ @prompt = response.prompt
185
+ if (diagnostic = @dialogue.diagnostic(response.raw))
186
+ raise error(DeviceError, diagnostic, phase: :command, command: command, output: response.raw), cause: nil
187
+ end
188
+
189
+ response
190
+ end
191
+ if command.sensitive? || interactions.any?(&:sensitive?)
192
+ @log.pause(&operation)
193
+ else
194
+ operation.call
195
+ end
196
+ end
197
+
198
+ private :exchange_command
199
+
200
+ # 按截止时间限制传输写入,并统一包装底层错误。
201
+ def write(bytes, deadline:, phase:, command: nil)
202
+ remaining = [deadline - Expect.monotonic, 0].max
203
+ transport.write(bytes, timeout: [remaining, configuration.write_timeout].min)
204
+ rescue => error
205
+ raise normalize_error(error, phase: phase, command: command), cause: nil
206
+ end
207
+
208
+ # 人工交互会结束当前连接,未完成的输入行不能在之后重放。
209
+ def interact(**options)
210
+ perform(:interact) do
211
+ @state = :interacting
212
+ begin
213
+ transport.interact(**options)
214
+ ensure
215
+ $ERROR_INFO ? close_preserving_failure : close_resources
216
+ end
217
+ end
218
+ end
219
+
220
+ # 创建带脱敏上下文、阶段、命令和输出尾部的领域错误。
221
+ def error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
222
+ sensitive = redactor.sensitive? || command&.sensitive?
223
+ message = safe_error_text(message, sensitive: sensitive)
224
+ output = safe_error_text(output, sensitive: sensitive)
225
+ klass.new(message, host: configuration.host, phase: phase,
226
+ command: command && (command.sensitive? ? "[REDACTED]" : redactor.call(command.text)),
227
+ source: command&.source, line: command&.line,
228
+ output: output.byteslice(-4096, 4096) || output,
229
+ underlying: underlying && UnderlyingError.new(underlying, redactor, sensitive: sensitive), **context)
230
+ end
231
+
232
+ # 将底层异常映射为连接、传输、超时或内部错误,并保留安全上下文。
233
+ def normalize_error(exception, phase:, command: nil)
234
+ if exception.is_a?(Error)
235
+ sensitive = redactor.sensitive? || command&.sensitive?
236
+ failed_command = exception.command || command&.text
237
+ if command&.sensitive? || (sensitive && exception.command && exception.command != command&.text)
238
+ failed_command = "[REDACTED]"
239
+ end
240
+ message = safe_error_text(exception.message, sensitive: sensitive)
241
+ output = safe_error_text(exception.output, sensitive: sensitive)
242
+ return exception.class.new(
243
+ message, code: exception.code,
244
+ host: exception.host || configuration.host, phase: exception.phase || phase,
245
+ command: failed_command && redactor.call(failed_command),
246
+ source: exception.source || command&.source, line: exception.line || command&.line,
247
+ output: output.byteslice(-4096, 4096) || output,
248
+ underlying: exception.underlying && UnderlyingError.new(exception.underlying, redactor, sensitive: sensitive)
249
+ )
250
+ end
251
+
252
+ klass = case exception
253
+ when Expect::WriteTimeout then WriteTimeout
254
+ when IOError, SystemCallError, Expect::SpawnError then TransportError
255
+ else InternalError
256
+ end
257
+ error(klass, "#{phase} failed: #{exception.class}", phase: phase, command: command, underlying: exception)
258
+ end
259
+
260
+ # 敏感上下文的任意异常和设备输出可能只包含局部秘密,保留类型和错误码诊断。
261
+ def safe_error_text(text, sensitive:)
262
+ sensitive && !text.to_s.empty? ? "[REDACTED]".b : redactor.call(text)
263
+ end
264
+
265
+ private :safe_error_text
266
+
267
+ # 返回不包含凭据的会话状态摘要。
268
+ def inspect = "#<#{self.class} state=#{state} host=#{configuration.host.inspect}>"
269
+
270
+ private
271
+
272
+ # 建立传输并登录;失败时最多执行一次连接级恢复,然后重试登录。
273
+ def connect_session
274
+ attempted_recovery = false
275
+ begin
276
+ @state = :connecting
277
+ redactor.reset
278
+ @log.open(transport)
279
+ @log.event("connect", host: configuration.host, username: configuration.username,
280
+ protocol: transport.respond_to?(:protocol) ? transport.protocol : configuration.protocol)
281
+ transport.open
282
+ @state = :authenticating
283
+ @log.event("login_start", level: :debug)
284
+ response = @authentication.login
285
+ safe_response = Response.new(raw: redactor.call(response.raw), output: redactor.call(response.output),
286
+ prompt: response.prompt)
287
+ @prompt = response.prompt
288
+ @after_login.call(self, safe_response)
289
+ @log.authentication_output(response.raw)
290
+ @log.event("login_complete", status: "ok", prompt: response.prompt)
291
+ @log.attach
292
+ @state = :ready
293
+ rescue => error
294
+ phase = (@state == :connecting) ? :connect : :login
295
+ failure = normalize_error(error, phase: phase)
296
+ @log.event(phase == :connect ? "connect_failed" : "login_complete", level: :error,
297
+ status: "failed", error: failure.class.name, message: failure.message, phase: phase)
298
+ close_preserving_failure
299
+ replacement = attempted_recovery ? nil : @recovery.recover(failure, transport)
300
+ if replacement
301
+ @transport = replacement
302
+ attempted_recovery = true
303
+ retry
304
+ end
305
+ raise failure, cause: nil
306
+ end
307
+ end
308
+
309
+ # 清除会话状态并关闭传输和日志资源。
310
+ def close_resources
311
+ @state = :closed
312
+ @privileged = false
313
+ @prompt = nil
314
+ begin
315
+ transport.close
316
+ ensure
317
+ @log.close
318
+ end
319
+ nil
320
+ rescue => error
321
+ raise normalize_error(error, phase: :close), cause: nil
322
+ end
323
+
324
+ # 尝试清理资源但不覆盖原始操作异常。
325
+ def close_preserving_failure
326
+ close_resources
327
+ rescue
328
+ # 所有清理路径完成后,原始操作异常仍然是权威结果。
329
+ nil
330
+ end
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "errors"
5
+
6
+ module Net
7
+ module Connector
8
+ # 把终端编辑控制符渲染成可读、兼容 UTF-8 和二进制的逐行日志。
9
+ class TerminalRenderer
10
+ # 将字节流完整渲染为字符串,供测试和一次性转换使用。
11
+ def self.render(input)
12
+ output = StringIO.new("".b)
13
+ renderer = new(output)
14
+ renderer.write(input)
15
+ renderer.finish
16
+ output.string
17
+ end
18
+
19
+ # 初始化当前行、光标和转义序列状态。
20
+ def initialize(target, max_line_bytes: 32 * 1024 * 1024)
21
+ raise ArgumentError, "target must respond to write" unless target.respond_to?(:write)
22
+ unless max_line_bytes.is_a?(Integer) && max_line_bytes.positive?
23
+ raise ArgumentError, "max_line_bytes must be positive"
24
+ end
25
+
26
+ @target = target
27
+ @line = "".b
28
+ @cursor = 0
29
+ @escape_state = nil
30
+ @csi_parameters = "".b
31
+ @max_line_bytes = max_line_bytes
32
+ end
33
+
34
+ # 逐字节消费终端输出,并返回输入字节数。
35
+ def write(input)
36
+ bytes = input.to_s.b
37
+ bytes.each_byte { |byte| consume(byte) }
38
+ bytes.bytesize
39
+ end
40
+
41
+ # 刷新底层日志目标。
42
+ def flush
43
+ @target.flush if @target.respond_to?(:flush)
44
+ end
45
+
46
+ # 写出尚未换行的尾行并刷新目标。
47
+ def finish
48
+ emit_line(newline: false) unless @line.empty?
49
+ flush
50
+ end
51
+
52
+ private
53
+
54
+ # 处理普通控制字符、换行、回车和转义序列起始符。
55
+ def consume(byte)
56
+ return consume_escape(byte) if @escape_state
57
+
58
+ case byte
59
+ when 7 then nil
60
+ when 8 then @cursor = [@cursor - 1, 0].max
61
+ when 10 then emit_line(newline: true)
62
+ when 13 then @cursor = 0
63
+ when 27 then @escape_state = :escape
64
+ else write_byte(byte) if byte == 9 || byte >= 32
65
+ end
66
+ end
67
+
68
+ # 根据转义序列阶段继续解析终端控制字节。
69
+ def consume_escape(byte)
70
+ case @escape_state
71
+ when :escape
72
+ start_escape_sequence(byte)
73
+ when :csi
74
+ finish_csi(byte)
75
+ when :osc
76
+ @escape_state = nil if byte == 7
77
+ @escape_state = :osc_escape if byte == 27
78
+ when :osc_escape
79
+ @escape_state = (byte == 92) ? nil : :osc
80
+ end
81
+ end
82
+
83
+ # 识别 CSI 或 OSC 序列,并忽略未知的单字节序列。
84
+ def start_escape_sequence(byte)
85
+ case byte
86
+ when 91
87
+ @csi_parameters.clear
88
+ @escape_state = :csi
89
+ when 93 then @escape_state = :osc
90
+ else
91
+ @escape_state = nil
92
+ end
93
+ end
94
+
95
+ # 收集 CSI 参数并在终止字节到达时执行光标操作。
96
+ def finish_csi(byte)
97
+ if byte.between?(0x40, 0x7e)
98
+ apply_csi(byte)
99
+ @escape_state = nil
100
+ elsif byte.between?(0x20, 0x3f)
101
+ raise OutputLimitExceeded, "terminal escape sequence is too long" if @csi_parameters.bytesize >= 64
102
+
103
+ @csi_parameters << byte
104
+ else
105
+ @escape_state = nil
106
+ end
107
+ end
108
+
109
+ # 应用有限范围内的光标移动和整行擦除控制。
110
+ def apply_csi(command)
111
+ count = Integer(@csi_parameters[/\d+/, 0] || "1", 10)
112
+ case command
113
+ when 67 then @cursor += count
114
+ when 68 then @cursor = [@cursor - count, 0].max
115
+ when 71 then @cursor = [count - 1, 0].max
116
+ when 75 then erase_line(Integer(@csi_parameters[/\d+/, 0] || "0", 10))
117
+ end
118
+ end
119
+
120
+ # 按终端擦除模式修改当前行缓冲区。
121
+ def erase_line(mode)
122
+ case mode
123
+ when 0 then @line = @line.byteslice(0, @cursor)
124
+ when 1
125
+ @line = (" " * [@cursor + 1, @line.bytesize].min) + @line.byteslice((@cursor + 1)..).to_s
126
+ when 2
127
+ @line.clear
128
+ @cursor = 0
129
+ end
130
+ end
131
+
132
+ # 在光标位置写入字节,并限制单行缓冲大小。
133
+ def write_byte(byte)
134
+ raise OutputLimitExceeded, "terminal line exceeded max_line_bytes" if @cursor >= @max_line_bytes
135
+
136
+ @line << (" " * (@cursor - @line.bytesize)) if @cursor > @line.bytesize
137
+ (@cursor < @line.bytesize) ? @line.setbyte(@cursor, byte) : @line << byte
138
+ @cursor += 1
139
+ end
140
+
141
+ # 删除行尾空白,写出当前行并重置光标。
142
+ def emit_line(newline:)
143
+ @target.write(utf8_safe(@line.sub(/[ \t]+\z/n, "")))
144
+ @target.write("\n") if newline
145
+ @line.clear
146
+ @cursor = 0
147
+ end
148
+
149
+ # 尽量保持有效 UTF-8;非法字节转义为可读的十六进制文本。
150
+ def utf8_safe(line)
151
+ text = line.dup.force_encoding(Encoding::UTF_8)
152
+ return text.b if text.valid_encoding?
153
+
154
+ text.scrub { |invalid| invalid.bytes.map { |byte| format("\\x%02X", byte) }.join }.b
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "expect/pty"
5
+
6
+ module Net
7
+ module Connector
8
+ # 传输事件使用从零开始的模式索引;最后一个额外索引表示流式数据。
9
+ Event = Struct.new(:index, :before, :match, :error) do
10
+ # 规范化事件的默认字节字段,便于读取器统一处理。
11
+ def initialize(index: nil, before: "".b, match: "".b, error: nil)
12
+ super
13
+ end
14
+
15
+ # 判断读取是否命中了模式而不是传输错误。
16
+ def matched? = error.nil?
17
+
18
+ # 返回事件索引和错误类型摘要。
19
+ def inspect = "#<#{self.class} index=#{index.inspect} error=#{error&.class}>"
20
+ end
21
+
22
+ # 可替换的字节传输层;厂商语法属于设备边界,不属于此层。
23
+ module Transports
24
+ # Expect 适配器拥有伪终端和进程,在限制未匹配尾部时不丢弃数据。
25
+ class Pty
26
+ # 即使读到完整行也保留 32 KiB,因为提示符可能跨越多次读取和多行。
27
+ STREAM = /\A[\s\S]{32768}(?=[\s\S]{32768})/n
28
+ attr_reader :configuration
29
+
30
+ # 保存配置和可替换的信道工厂,便于测试和设备差异注入。
31
+ def initialize(configuration, channel_factory: nil, terminal_size: nil)
32
+ @configuration = configuration
33
+ @channel_factory = channel_factory
34
+ @terminal_size = terminal_size
35
+ end
36
+
37
+ # 打开伪终端、设置终端大小并启动设备进程。
38
+ def open
39
+ configuration.validate_endpoint!
40
+ @channel = if @channel_factory
41
+ @channel_factory.call
42
+ else
43
+ Expect.new(raw_pty: true, reset_timeout_on_read: false, buffer_limit: nil,
44
+ preserve_buffer: false, log_stdout: false, log_listeners: false,
45
+ debug_level: 0, write_timeout: configuration.write_timeout)
46
+ end
47
+ # Profile 使用宽、高;PTY 使用行数、列数。
48
+ @channel.slave.winsize = @terminal_size.reverse if @terminal_size
49
+ @channel.log_output = method(:record)
50
+ @channel.spawn(*argv)
51
+ self
52
+ rescue Exception # rubocop:disable Lint/RescueException -- A half-open PTY must be released on interrupts.
53
+ begin
54
+ close
55
+ rescue
56
+ # 尝试释放半打开信道后,仍然抛出原始失败。
57
+ end
58
+ raise
59
+ end
60
+
61
+ # 从信道读取下一事件,并把字符串字段转换为字节字符串。
62
+ def read(patterns, timeout:)
63
+ result = @channel.expect_result(*patterns, STREAM, timeout: timeout)
64
+ Event.new(index: result.number && (result.number - 1), before: result.before.to_s.b,
65
+ match: result.match.to_s.b, error: result.error)
66
+ end
67
+
68
+ # 临时设置写入超时,完成写入后恢复原设置。
69
+ def write(bytes, timeout: configuration.write_timeout)
70
+ previous = @channel.write_timeout
71
+ @channel.write_timeout = timeout
72
+ @channel.write(bytes)
73
+ ensure
74
+ @channel.write_timeout = previous if @channel
75
+ end
76
+
77
+ # 判断信道尚未建立或已经关闭。
78
+ def closed? = !@channel || @channel.closed?
79
+
80
+ # 设置日志输出目标;nil 表示暂时停止记录。
81
+ def log_output=(target)
82
+ @log_target = target
83
+ end
84
+
85
+ # 清除信道和日志目标,并强制关闭底层进程。
86
+ def close
87
+ channel = @channel
88
+ @channel = nil
89
+ @log_target = nil
90
+ channel&.hard_close
91
+ end
92
+
93
+ # 把人工交互结果转换为设备 EOF、超时或输入状态。
94
+ def interact(**)
95
+ stopped = @channel.interact(**)
96
+ return :device_eof if stopped.equal?(@channel)
97
+
98
+ stopped.nil? ? :timeout : :input
99
+ end
100
+
101
+ # 返回传输主机和关闭状态摘要。
102
+ def inspect = "#<#{self.class} host=#{configuration.host.inspect} closed=#{closed?}>"
103
+
104
+ private
105
+
106
+ # 检查缓冲上限并把设备输出写入当前日志目标。
107
+ def record(bytes)
108
+ if @channel.buffer.bytesize > configuration.max_output_bytes
109
+ raise OutputLimitExceeded.new("transport buffer exceeded max_output_bytes",
110
+ phase: :read, output: @channel.buffer.dup), cause: nil
111
+ end
112
+
113
+ @log_target&.write(bytes)
114
+ @log_target.flush if @log_target.respond_to?(:flush)
115
+ end
116
+ end
117
+
118
+ # 生成 OpenSSH 参数,并处理显式请求的旧版协商和主机密钥操作。
119
+ class Ssh < Pty
120
+ attr_reader :legacy_arguments
121
+
122
+ # 保存旧版 SSH 参数并复用伪终端初始化。
123
+ def initialize(configuration, legacy_arguments: [], **)
124
+ super(configuration, **)
125
+ @legacy_arguments = legacy_arguments.dup.freeze
126
+ end
127
+
128
+ # 返回 SSH 协议标识。
129
+ def protocol = :ssh
130
+
131
+ # 根据配置生成经过端点校验的 SSH 参数数组。
132
+ def argv
133
+ configuration.validate_endpoint!
134
+ checking = (configuration.host_key_policy == :strict) ? "yes" : "accept-new"
135
+ arguments = ["ssh", *legacy_arguments, "-tt", "-o", "StrictHostKeyChecking=#{checking}",
136
+ "-o", "NumberOfPasswordPrompts=1",
137
+ "-o", "ConnectTimeout=#{[configuration.login_timeout.ceil, 1].max}"]
138
+ arguments += ["-o", "UserKnownHostsFile=#{configuration.known_hosts}"] if configuration.known_hosts
139
+ arguments += ["-p", configuration.port.to_s] if configuration.port
140
+ arguments + ["-l", configuration.username, configuration.host]
141
+ end
142
+
143
+ # 创建带旧版协商参数的同类传输对象。
144
+ def with_legacy(arguments)
145
+ self.class.new(configuration, legacy_arguments: arguments, channel_factory: @channel_factory,
146
+ terminal_size: @terminal_size)
147
+ end
148
+
149
+ # 创建不继承 SSH 专用端口的 Telnet 传输对象。
150
+ def as_telnet
151
+ Telnet.new(configuration, channel_factory: @channel_factory, terminal_size: @terminal_size)
152
+ end
153
+
154
+ # 从指定 known_hosts 文件中删除当前设备的旧主机密钥。
155
+ def replace_host_key
156
+ host = configuration.host
157
+ host = "[#{host}]:#{configuration.port}" if configuration.port && configuration.port != 22
158
+ _output, status = Open3.capture2e("ssh-keygen", "-f", configuration.known_hosts, "-R", host)
159
+ raise IOError, "removing device host key failed" unless status.success?
160
+ end
161
+ end
162
+
163
+ # Telnet 只能显式启用;SSH 专用端口不能带入回退连接。
164
+ class Telnet < Pty
165
+ # 返回 Telnet 协议标识。
166
+ def protocol = :telnet
167
+
168
+ # 根据配置生成经过端点校验的 Telnet 参数数组。
169
+ def argv
170
+ configuration.validate_endpoint!
171
+ port = (configuration.protocol == :telnet) ? configuration.port : nil
172
+ ["telnet", "-l", configuration.username, configuration.host, *(port ? [port.to_s] : [])]
173
+ end
174
+ end
175
+
176
+ TYPES = { ssh: Ssh, telnet: Telnet }.freeze
177
+
178
+ # 按协议选择具体传输实现。
179
+ def self.build(configuration, **)
180
+ TYPES.fetch(configuration.protocol).new(configuration, **)
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # 保留原设备门面入口;只需要会话执行层时加载 engine/core。
4
+ require_relative "device/base"