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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +82 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/docs/RELEASING.md +81 -0
- data/docs/VERIFICATION.md +73 -0
- data/docs/architecture.md +268 -0
- data/exe/net-connector-backup +6 -0
- data/lib/net/connector/device/base.rb +368 -0
- data/lib/net/connector/device/interface_description.rb +41 -0
- data/lib/net/connector/device/interface_name.rb +56 -0
- data/lib/net/connector/device/profile.rb +446 -0
- data/lib/net/connector/device/running_config/strategy.rb +29 -0
- data/lib/net/connector/device/running_config.rb +69 -0
- data/lib/net/connector/engine/authentication.rb +77 -0
- data/lib/net/connector/engine/base.rb +4 -0
- data/lib/net/connector/engine/command.rb +101 -0
- data/lib/net/connector/engine/configuration.rb +131 -0
- data/lib/net/connector/engine/core.rb +6 -0
- data/lib/net/connector/engine/dialogue.rb +223 -0
- data/lib/net/connector/engine/errors.rb +183 -0
- data/lib/net/connector/engine/execution.rb +64 -0
- data/lib/net/connector/engine/log.rb +227 -0
- data/lib/net/connector/engine/log_messages.rb +47 -0
- data/lib/net/connector/engine/profile.rb +4 -0
- data/lib/net/connector/engine/recovery.rb +57 -0
- data/lib/net/connector/engine/result.rb +67 -0
- data/lib/net/connector/engine/session.rb +333 -0
- data/lib/net/connector/engine/terminal_renderer.rb +158 -0
- data/lib/net/connector/engine/transport.rb +184 -0
- data/lib/net/connector/engine.rb +4 -0
- data/lib/net/connector/netdisco/batch.rb +71 -0
- data/lib/net/connector/netdisco/cli.rb +142 -0
- data/lib/net/connector/netdisco/client.rb +148 -0
- data/lib/net/connector/netdisco/config_file.rb +87 -0
- data/lib/net/connector/netdisco/device.rb +75 -0
- data/lib/net/connector/netdisco/fleet.rb +258 -0
- data/lib/net/connector/netdisco/planner.rb +77 -0
- data/lib/net/connector/netdisco/result_store.rb +43 -0
- data/lib/net/connector/netdisco/rules.rb +131 -0
- data/lib/net/connector/netdisco/settings.rb +183 -0
- data/lib/net/connector/netdisco/worker.rb +75 -0
- data/lib/net/connector/netdisco.rb +14 -0
- data/lib/net/connector/operations/local_backup.rb +49 -0
- data/lib/net/connector/operations/parse_output.rb +60 -0
- data/lib/net/connector/operations/private_file.rb +25 -0
- data/lib/net/connector/operations/running_config/cisco.rb +14 -0
- data/lib/net/connector/operations/running_config/hillstone.rb +12 -0
- data/lib/net/connector/operations/running_config/palo_alto.rb +12 -0
- data/lib/net/connector/operations/running_config/strategy.rb +3 -0
- data/lib/net/connector/operations/running_config.rb +16 -0
- data/lib/net/connector/operations/saved_config.rb +72 -0
- data/lib/net/connector/operations/tftp/cisco_ios.rb +11 -0
- data/lib/net/connector/operations/tftp/cisco_nxos.rb +11 -0
- data/lib/net/connector/operations/tftp/h3c.rb +11 -0
- data/lib/net/connector/operations/tftp/hillstone.rb +11 -0
- data/lib/net/connector/operations/tftp/huawei.rb +11 -0
- data/lib/net/connector/operations/tftp/palo_alto.rb +11 -0
- data/lib/net/connector/operations/tftp/radware.rb +11 -0
- data/lib/net/connector/operations/tftp/strategy.rb +77 -0
- data/lib/net/connector/operations/tftp_backup.rb +120 -0
- data/lib/net/connector/operations/topology/cisco.rb +11 -0
- data/lib/net/connector/operations/topology/h3c.rb +11 -0
- data/lib/net/connector/operations/topology/hillstone.rb +11 -0
- data/lib/net/connector/operations/topology/palo_alto.rb +11 -0
- data/lib/net/connector/operations/topology/radware.rb +11 -0
- data/lib/net/connector/operations/topology/strategy.rb +53 -0
- data/lib/net/connector/operations/topology.rb +192 -0
- data/lib/net/connector/operations.rb +20 -0
- data/lib/net/connector/templates/cisco_cdp_neighbors_detail.textfsm +7 -0
- data/lib/net/connector/templates/cisco_ios_running_config_interfaces.textfsm +12 -0
- data/lib/net/connector/templates/cisco_ios_show_ip_interface_brief.textfsm +7 -0
- data/lib/net/connector/templates/h3c_interface_descriptions.textfsm +11 -0
- data/lib/net/connector/templates/h3c_lldp_local_first.textfsm +8 -0
- data/lib/net/connector/templates/h3c_lldp_name_first.textfsm +8 -0
- data/lib/net/connector/templates/hillstone_interface_descriptions.textfsm +11 -0
- data/lib/net/connector/templates/hillstone_lldp_neighbors.textfsm +7 -0
- data/lib/net/connector/templates/index +8 -0
- data/lib/net/connector/templates/palo_alto_interface_descriptions.textfsm +6 -0
- data/lib/net/connector/templates/palo_alto_lldp_neighbors.textfsm +11 -0
- data/lib/net/connector/templates/radware_port_names.textfsm +13 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +19 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +32 -0
- data/lib/net/connector/vendor/cisco_ios/topology.rb +33 -0
- data/lib/net/connector/vendor/cisco_ios.rb +48 -0
- data/lib/net/connector/vendor/cisco_nxos/running_config.rb +22 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +34 -0
- data/lib/net/connector/vendor/cisco_nxos.rb +55 -0
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +43 -0
- data/lib/net/connector/vendor/h3c/topology.rb +56 -0
- data/lib/net/connector/vendor/h3c.rb +77 -0
- data/lib/net/connector/vendor/h3c_wireless.rb +14 -0
- data/lib/net/connector/vendor/hillstone/running_config.rb +13 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +40 -0
- data/lib/net/connector/vendor/hillstone/topology.rb +48 -0
- data/lib/net/connector/vendor/hillstone.rb +43 -0
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +34 -0
- data/lib/net/connector/vendor/huawei.rb +64 -0
- data/lib/net/connector/vendor/palo_alto/running_config.rb +70 -0
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +36 -0
- data/lib/net/connector/vendor/palo_alto/topology.rb +63 -0
- data/lib/net/connector/vendor/palo_alto.rb +43 -0
- data/lib/net/connector/vendor/radware/tftp_backup.rb +40 -0
- data/lib/net/connector/vendor/radware/topology.rb +14 -0
- data/lib/net/connector/vendor/radware.rb +62 -0
- data/lib/net/connector/version.rb +7 -0
- data/lib/net/connector.rb +48 -0
- data/lib/net-connector.rb +3 -0
- 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
|