net-connector 0.5.0 → 0.7.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/CHANGELOG.md +58 -0
- data/CONTRIBUTING.md +20 -0
- data/README.md +153 -42
- data/docs/VERIFICATION.md +42 -1
- data/docs/architecture.md +20 -1
- data/examples/backup.yml +40 -0
- data/examples/inventory_sql.yml +16 -0
- data/lib/net/connector/device/base.rb +30 -38
- data/lib/net/connector/device/profile/builder.rb +8 -8
- data/lib/net/connector/device/profile.rb +2 -2
- data/lib/net/connector/device/running_config.rb +2 -2
- data/lib/net/connector/device/tftp/receipt.rb +12 -3
- data/lib/net/connector/device/tftp.rb +7 -7
- data/lib/net/connector/device/topology.rb +4 -4
- data/lib/net/connector/engine/authentication.rb +1 -1
- data/lib/net/connector/engine/command.rb +2 -2
- data/lib/net/connector/engine/configuration.rb +5 -2
- data/lib/net/connector/engine/dialogue.rb +4 -4
- data/lib/net/connector/engine/execution.rb +27 -16
- data/lib/net/connector/engine/known_hosts.rb +112 -0
- data/lib/net/connector/engine/log.rb +10 -7
- data/lib/net/connector/engine/session.rb +117 -128
- data/lib/net/connector/engine/transport.rb +32 -7
- data/lib/net/connector/netdisco/backup_run.rb +132 -0
- data/lib/net/connector/netdisco/batch.rb +1 -1
- data/lib/net/connector/netdisco/cli/options.rb +139 -0
- data/lib/net/connector/netdisco/cli.rb +20 -24
- data/lib/net/connector/netdisco/client.rb +8 -7
- data/lib/net/connector/netdisco/config_file.rb +26 -22
- data/lib/net/connector/netdisco/connection.rb +38 -0
- data/lib/net/connector/netdisco/database_client.rb +197 -0
- data/lib/net/connector/netdisco/device.rb +11 -1
- data/lib/net/connector/netdisco/fleet.rb +68 -47
- data/lib/net/connector/netdisco/inventory_budget.rb +4 -4
- data/lib/net/connector/netdisco/plan.rb +14 -9
- data/lib/net/connector/netdisco/planner.rb +18 -14
- data/lib/net/connector/netdisco/progress.rb +253 -0
- data/lib/net/connector/netdisco/report/files.rb +41 -0
- data/lib/net/connector/netdisco/report/text.rb +45 -0
- data/lib/net/connector/netdisco/report.rb +53 -31
- data/lib/net/connector/netdisco/result_store.rb +3 -1
- data/lib/net/connector/netdisco/rules.rb +10 -2
- data/lib/net/connector/netdisco/settings.rb +136 -63
- data/lib/net/connector/netdisco/tftp_archive.rb +175 -0
- data/lib/net/connector/netdisco/tftp_history.rb +16 -0
- data/lib/net/connector/netdisco/tftp_verification.rb +58 -0
- data/lib/net/connector/netdisco/worker.rb +8 -3
- data/lib/net/connector/netdisco.rb +11 -0
- data/lib/net/connector/storage/backup_lock.rb +1 -1
- data/lib/net/connector/storage/batch_directory.rb +29 -0
- data/lib/net/connector/storage.rb +1 -0
- data/lib/net/connector/vendor/h3c.rb +2 -1
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/radware/running_config.rb +19 -0
- data/lib/net/connector/vendor/radware.rb +4 -3
- data/lib/net/connector/version.rb +1 -1
- data/lib/net/connector.rb +2 -2
- metadata +57 -104
|
@@ -57,15 +57,15 @@ module Net
|
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
def with_operation_context(name, &
|
|
61
|
-
with_context(operation: name || @context[:operation] || :script, &
|
|
60
|
+
def with_operation_context(name, &)
|
|
61
|
+
with_context(operation: name || @context[:operation] || :script, &)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
def with_command_context(command, &
|
|
64
|
+
def with_command_context(command, &)
|
|
65
65
|
@command_sequence += 1
|
|
66
66
|
with_context(command_id: @command_sequence, phase: :command,
|
|
67
67
|
text: command.sensitive? ? "[REDACTED]" : command.text,
|
|
68
|
-
source: command.source, line: command.line, &
|
|
68
|
+
source: command.source, line: command.line, &)
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
# 将终端回显接入当前会话日志。
|
|
@@ -81,7 +81,7 @@ module Net
|
|
|
81
81
|
|
|
82
82
|
# 按日志级别写入已脱敏的业务事件。
|
|
83
83
|
def log_event(name, level: :info, **fields)
|
|
84
|
-
return unless @logger
|
|
84
|
+
return unless @logger || @configuration.on_event
|
|
85
85
|
|
|
86
86
|
finish_output
|
|
87
87
|
write_event(name, level: level, **fields)
|
|
@@ -190,7 +190,8 @@ module Net
|
|
|
190
190
|
|
|
191
191
|
# 每次读取调用方的当前阈值,不缓存级别、不改写共享 Logger。
|
|
192
192
|
def enabled?(level)
|
|
193
|
-
|
|
193
|
+
LEVELS.fetch(level) >= LEVELS.fetch(@configuration.log_level) &&
|
|
194
|
+
(@configuration.on_event || (@logger && LEVELS.fetch(level) >= @logger.level))
|
|
194
195
|
end
|
|
195
196
|
|
|
196
197
|
def write_event(name, level:, **fields)
|
|
@@ -198,7 +199,9 @@ module Net
|
|
|
198
199
|
|
|
199
200
|
values = @context.compact.merge(fields.reject { |key, _| CONTEXT_FIELDS.include?(key.to_s) })
|
|
200
201
|
values = values.merge(host: @configuration.host, session_id: @session_id)
|
|
201
|
-
|
|
202
|
+
event = Event.new(name, values, redactor: @redactor)
|
|
203
|
+
@logger.public_send(level, event) if @logger && LEVELS.fetch(level) >= @logger.level
|
|
204
|
+
@configuration.on_event&.call(event)
|
|
202
205
|
end
|
|
203
206
|
|
|
204
207
|
def open_transcript
|
|
@@ -30,13 +30,10 @@ module Net
|
|
|
30
30
|
@lock = Mutex.new
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
# 判断会话状态和传输层是否都表明连接仍然有效。
|
|
34
33
|
def connected? = state != :closed && !transport.closed?
|
|
35
34
|
|
|
36
|
-
# 返回当前是否已经进入特权模式。
|
|
37
35
|
def privileged? = @privileged
|
|
38
36
|
|
|
39
|
-
# 标记当前会话已进入特权模式。
|
|
40
37
|
def mark_privileged! = @privileged = true
|
|
41
38
|
|
|
42
39
|
# 记录脚本之外的业务结果,例如设备报告的 TFTP 上传状态。
|
|
@@ -63,16 +60,16 @@ module Net
|
|
|
63
60
|
end
|
|
64
61
|
|
|
65
62
|
# 锁覆盖完整操作而不是单次写入,保证批处理命令不会交错。
|
|
66
|
-
def perform(phase)
|
|
63
|
+
def perform(phase, &)
|
|
67
64
|
if @operation_owner == [Thread.current, Fiber.current] && !@performing
|
|
68
|
-
return perform_locked(phase)
|
|
65
|
+
return perform_locked(phase, &)
|
|
69
66
|
end
|
|
70
67
|
unless @lock.try_lock
|
|
71
68
|
raise build_error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
|
|
72
69
|
end
|
|
73
70
|
|
|
74
71
|
begin
|
|
75
|
-
perform_locked(phase)
|
|
72
|
+
perform_locked(phase, &)
|
|
76
73
|
ensure
|
|
77
74
|
@lock.unlock
|
|
78
75
|
end
|
|
@@ -106,37 +103,6 @@ module Net
|
|
|
106
103
|
raise build_error(SessionBusy, "backup path ownership must be acquired before a session operation", phase: phase), cause: nil
|
|
107
104
|
end
|
|
108
105
|
|
|
109
|
-
# 一次操作从连接到结果处理始终持锁;异常或 throw 中断都关闭未完成会话。
|
|
110
|
-
def perform_locked(phase)
|
|
111
|
-
@performing = true
|
|
112
|
-
completed = false
|
|
113
|
-
begin
|
|
114
|
-
unless connected?
|
|
115
|
-
close_resources unless state == :closed
|
|
116
|
-
connect_session
|
|
117
|
-
end
|
|
118
|
-
@state = (phase == :connect) ? :ready : :executing
|
|
119
|
-
result = yield
|
|
120
|
-
completed = true
|
|
121
|
-
result
|
|
122
|
-
rescue => error
|
|
123
|
-
failure = normalize_error(error, phase: phase)
|
|
124
|
-
close_preserving_failure
|
|
125
|
-
raise failure, cause: nil
|
|
126
|
-
rescue Exception # rubocop:disable Lint/RescueException -- Interrupts release the transport, then propagate.
|
|
127
|
-
close_preserving_failure
|
|
128
|
-
raise
|
|
129
|
-
ensure
|
|
130
|
-
# throw/catch 也会离开此块;对话未完成时必须关闭连接,不能把
|
|
131
|
-
# 半途的设备提示符误当成下一条命令的响应。
|
|
132
|
-
close_preserving_failure unless completed || !connected?
|
|
133
|
-
@state = :ready if connected?
|
|
134
|
-
@performing = false
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
private :perform_locked
|
|
139
|
-
|
|
140
106
|
# 独占关闭会话;已有操作占用时返回会话繁忙错误。
|
|
141
107
|
def close
|
|
142
108
|
unless @lock.try_lock
|
|
@@ -195,6 +161,99 @@ module Net
|
|
|
195
161
|
end
|
|
196
162
|
end
|
|
197
163
|
|
|
164
|
+
# 按截止时间限制传输写入,并统一包装底层错误。
|
|
165
|
+
def write(bytes, deadline:, phase:, command: nil)
|
|
166
|
+
remaining = [deadline - Expect.monotonic, 0].max
|
|
167
|
+
transport.write(bytes, timeout: [remaining, configuration.write_timeout].min)
|
|
168
|
+
rescue => error
|
|
169
|
+
raise normalize_error(error, phase: phase, command: command), cause: nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# 人工交互会结束当前连接,未完成的输入行不能在之后重放。
|
|
173
|
+
def interact(**options)
|
|
174
|
+
perform(:interact) do
|
|
175
|
+
@state = :interacting
|
|
176
|
+
begin
|
|
177
|
+
transport.interact(**options)
|
|
178
|
+
ensure
|
|
179
|
+
$ERROR_INFO ? close_preserving_failure : close_resources
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# 创建带脱敏上下文、阶段、命令和输出尾部的领域错误。
|
|
185
|
+
def build_error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
|
|
186
|
+
sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
|
|
187
|
+
message = safe_error_text(message, sensitive: sensitive)
|
|
188
|
+
output = safe_error_text(output, sensitive: sensitive)
|
|
189
|
+
klass.new(message, host: configuration.host, phase: phase,
|
|
190
|
+
command: command && (command.sensitive? ? "[REDACTED]" : redactor.call(command.text)),
|
|
191
|
+
source: command&.source, line: command&.line,
|
|
192
|
+
output: truncate_output(output),
|
|
193
|
+
underlying: underlying && UnderlyingError.new(underlying, redactor, sensitive: sensitive), **context)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# 将底层异常映射为连接、传输、超时或内部错误,并保留安全上下文。
|
|
197
|
+
def normalize_error(exception, phase:, command: nil)
|
|
198
|
+
if exception.is_a?(Error)
|
|
199
|
+
sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
|
|
200
|
+
failed_command = exception.command || command&.text
|
|
201
|
+
if command&.sensitive? || (sensitive && exception.command && exception.command != command&.text)
|
|
202
|
+
failed_command = "[REDACTED]"
|
|
203
|
+
end
|
|
204
|
+
message = safe_error_text(exception.message, sensitive: sensitive)
|
|
205
|
+
output = safe_error_text(exception.output, sensitive: sensitive)
|
|
206
|
+
return exception.with_diagnostics(
|
|
207
|
+
message: message,
|
|
208
|
+
host: exception.host || configuration.host, phase: exception.phase || phase,
|
|
209
|
+
command: failed_command && redactor.call(failed_command),
|
|
210
|
+
source: exception.source || command&.source, line: exception.line || command&.line,
|
|
211
|
+
output: truncate_output(output),
|
|
212
|
+
underlying: exception.underlying && UnderlyingError.new(exception.underlying, redactor, sensitive: sensitive)
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
klass = case exception
|
|
217
|
+
when Expect::WriteTimeout then WriteTimeout
|
|
218
|
+
when IOError, SystemCallError, Expect::SpawnError then TransportError
|
|
219
|
+
else InternalError
|
|
220
|
+
end
|
|
221
|
+
build_error(klass, "#{phase} failed: #{exception.class}", phase: phase, command: command, underlying: exception)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def inspect = "#<#{self.class} state=#{state} host=#{configuration.host.inspect}>"
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
# 一次操作从连接到结果处理始终持锁;异常或 throw 中断都关闭未完成会话。
|
|
229
|
+
def perform_locked(phase)
|
|
230
|
+
@performing = true
|
|
231
|
+
completed = false
|
|
232
|
+
begin
|
|
233
|
+
unless connected?
|
|
234
|
+
close_resources unless state == :closed
|
|
235
|
+
connect_session
|
|
236
|
+
end
|
|
237
|
+
@state = (phase == :connect) ? :ready : :executing
|
|
238
|
+
result = yield
|
|
239
|
+
completed = true
|
|
240
|
+
result
|
|
241
|
+
rescue => error
|
|
242
|
+
failure = normalize_error(error, phase: phase)
|
|
243
|
+
close_preserving_failure
|
|
244
|
+
raise failure, cause: nil
|
|
245
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Interrupts release the transport, then propagate.
|
|
246
|
+
close_preserving_failure
|
|
247
|
+
raise
|
|
248
|
+
ensure
|
|
249
|
+
# throw/catch 也会离开此块;对话未完成时必须关闭连接,不能把
|
|
250
|
+
# 半途的设备提示符误当成下一条命令的响应。
|
|
251
|
+
close_preserving_failure unless completed || !connected?
|
|
252
|
+
@state = :ready if connected?
|
|
253
|
+
@performing = false
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
198
257
|
def execute_command_with_logging(command, timeout:, prompt:)
|
|
199
258
|
started = Expect.monotonic
|
|
200
259
|
@log.log_event("command_start")
|
|
@@ -224,8 +283,6 @@ module Net
|
|
|
224
283
|
end
|
|
225
284
|
end
|
|
226
285
|
|
|
227
|
-
private :execute_command_with_logging, :elapsed_ms, :log_operation_completion
|
|
228
|
-
|
|
229
286
|
# 在用户钩子运行前标记敏感上下文,动态交互尚未返回时也能保护其异常。
|
|
230
287
|
def protect_command(command)
|
|
231
288
|
redactor.output_sensitive! if command.output_sensitive?
|
|
@@ -235,8 +292,6 @@ module Net
|
|
|
235
292
|
redactor.remember(command.text) if command.sensitive?
|
|
236
293
|
end
|
|
237
294
|
|
|
238
|
-
private :protect_command
|
|
239
|
-
|
|
240
295
|
# 写入命令、读取对话、识别设备诊断,并返回响应对象。
|
|
241
296
|
def exchange_command(command, timeout:, prompt: nil)
|
|
242
297
|
deadline = Expect.monotonic + (command.timeout || timeout)
|
|
@@ -260,109 +315,23 @@ module Net
|
|
|
260
315
|
end
|
|
261
316
|
end
|
|
262
317
|
|
|
263
|
-
private :exchange_command
|
|
264
|
-
|
|
265
|
-
# 按截止时间限制传输写入,并统一包装底层错误。
|
|
266
|
-
def write(bytes, deadline:, phase:, command: nil)
|
|
267
|
-
remaining = [deadline - Expect.monotonic, 0].max
|
|
268
|
-
transport.write(bytes, timeout: [remaining, configuration.write_timeout].min)
|
|
269
|
-
rescue => error
|
|
270
|
-
raise normalize_error(error, phase: phase, command: command), cause: nil
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
# 人工交互会结束当前连接,未完成的输入行不能在之后重放。
|
|
274
|
-
def interact(**options)
|
|
275
|
-
perform(:interact) do
|
|
276
|
-
@state = :interacting
|
|
277
|
-
begin
|
|
278
|
-
transport.interact(**options)
|
|
279
|
-
ensure
|
|
280
|
-
$ERROR_INFO ? close_preserving_failure : close_resources
|
|
281
|
-
end
|
|
282
|
-
end
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
# 创建带脱敏上下文、阶段、命令和输出尾部的领域错误。
|
|
286
|
-
def build_error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
|
|
287
|
-
sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
|
|
288
|
-
message = safe_error_text(message, sensitive: sensitive)
|
|
289
|
-
output = safe_error_text(output, sensitive: sensitive)
|
|
290
|
-
klass.new(message, host: configuration.host, phase: phase,
|
|
291
|
-
command: command && (command.sensitive? ? "[REDACTED]" : redactor.call(command.text)),
|
|
292
|
-
source: command&.source, line: command&.line,
|
|
293
|
-
output: truncate_output(output),
|
|
294
|
-
underlying: underlying && UnderlyingError.new(underlying, redactor, sensitive: sensitive), **context)
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
# 将底层异常映射为连接、传输、超时或内部错误,并保留安全上下文。
|
|
298
|
-
def normalize_error(exception, phase:, command: nil)
|
|
299
|
-
if exception.is_a?(Error)
|
|
300
|
-
sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
|
|
301
|
-
failed_command = exception.command || command&.text
|
|
302
|
-
if command&.sensitive? || (sensitive && exception.command && exception.command != command&.text)
|
|
303
|
-
failed_command = "[REDACTED]"
|
|
304
|
-
end
|
|
305
|
-
message = safe_error_text(exception.message, sensitive: sensitive)
|
|
306
|
-
output = safe_error_text(exception.output, sensitive: sensitive)
|
|
307
|
-
return exception.with_diagnostics(
|
|
308
|
-
message: message,
|
|
309
|
-
host: exception.host || configuration.host, phase: exception.phase || phase,
|
|
310
|
-
command: failed_command && redactor.call(failed_command),
|
|
311
|
-
source: exception.source || command&.source, line: exception.line || command&.line,
|
|
312
|
-
output: truncate_output(output),
|
|
313
|
-
underlying: exception.underlying && UnderlyingError.new(exception.underlying, redactor, sensitive: sensitive)
|
|
314
|
-
)
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
klass = case exception
|
|
318
|
-
when Expect::WriteTimeout then WriteTimeout
|
|
319
|
-
when IOError, SystemCallError, Expect::SpawnError then TransportError
|
|
320
|
-
else InternalError
|
|
321
|
-
end
|
|
322
|
-
build_error(klass, "#{phase} failed: #{exception.class}", phase: phase, command: command, underlying: exception)
|
|
323
|
-
end
|
|
324
|
-
|
|
325
318
|
# 敏感上下文的任意异常和设备输出可能只包含局部秘密,保留类型和错误码诊断。
|
|
326
319
|
def safe_error_text(text, sensitive:)
|
|
327
320
|
sensitive && !text.to_s.empty? ? "[REDACTED]".b : redactor.call(text)
|
|
328
321
|
end
|
|
329
322
|
|
|
330
|
-
private :safe_error_text
|
|
331
|
-
|
|
332
323
|
# 必须先脱敏再截取尾部,避免截断凭据后逃过完整词匹配。
|
|
333
324
|
def truncate_output(text)
|
|
334
325
|
text.byteslice(-MAX_ERROR_OUTPUT_BYTES, MAX_ERROR_OUTPUT_BYTES) || text
|
|
335
326
|
end
|
|
336
327
|
|
|
337
|
-
private :truncate_output
|
|
338
|
-
|
|
339
|
-
# 返回不包含凭据的会话状态摘要。
|
|
340
|
-
def inspect = "#<#{self.class} state=#{state} host=#{configuration.host.inspect}>"
|
|
341
|
-
|
|
342
|
-
private
|
|
343
|
-
|
|
344
328
|
# 建立传输并登录;失败时最多执行一次连接级恢复,然后重试登录。
|
|
345
329
|
def connect_session
|
|
346
330
|
attempted_recovery = false
|
|
347
331
|
begin
|
|
348
332
|
@state = :connecting
|
|
349
333
|
started = Expect.monotonic
|
|
350
|
-
|
|
351
|
-
@log.open(transport)
|
|
352
|
-
@log.log_event("connect", host: configuration.host, username: configuration.username,
|
|
353
|
-
phase: :connect, protocol: transport.respond_to?(:protocol) ? transport.protocol : configuration.protocol)
|
|
354
|
-
transport.open
|
|
355
|
-
@state = :authenticating
|
|
356
|
-
@log.log_event("login_start", level: :debug, phase: :login)
|
|
357
|
-
response = @authentication.login
|
|
358
|
-
safe_response = Response.new(raw: redactor.call(response.raw), output: redactor.call(response.output),
|
|
359
|
-
prompt: response.prompt)
|
|
360
|
-
@prompt = response.prompt
|
|
361
|
-
@after_login.call(self, safe_response)
|
|
362
|
-
@log.log_authentication_output(response.raw)
|
|
363
|
-
@log.log_event("login_complete", status: "ok", prompt: response.prompt, phase: :login, duration_ms: elapsed_ms(started))
|
|
364
|
-
@log.attach
|
|
365
|
-
@state = :ready
|
|
334
|
+
login_once(started)
|
|
366
335
|
rescue => error
|
|
367
336
|
phase = (@state == :connecting) ? :connect : :login
|
|
368
337
|
failure = normalize_error(error, phase: phase)
|
|
@@ -379,6 +348,26 @@ module Net
|
|
|
379
348
|
end
|
|
380
349
|
end
|
|
381
350
|
|
|
351
|
+
def login_once(started)
|
|
352
|
+
redactor.reset
|
|
353
|
+
@log.open(transport)
|
|
354
|
+
@log.log_event("connect", host: configuration.host, username: configuration.username,
|
|
355
|
+
phase: :connect, protocol: transport.respond_to?(:protocol) ? transport.protocol : configuration.protocol)
|
|
356
|
+
transport.open
|
|
357
|
+
@state = :authenticating
|
|
358
|
+
@log.log_event("login_start", level: :debug, phase: :login)
|
|
359
|
+
response = @authentication.login
|
|
360
|
+
transport.authenticated if transport.respond_to?(:authenticated)
|
|
361
|
+
safe_response = Response.new(raw: redactor.call(response.raw), output: redactor.call(response.output),
|
|
362
|
+
prompt: response.prompt)
|
|
363
|
+
@prompt = response.prompt
|
|
364
|
+
@after_login.call(self, safe_response)
|
|
365
|
+
@log.log_authentication_output(response.raw)
|
|
366
|
+
@log.log_event("login_complete", status: "ok", prompt: response.prompt, phase: :login, duration_ms: elapsed_ms(started))
|
|
367
|
+
@log.attach
|
|
368
|
+
@state = :ready
|
|
369
|
+
end
|
|
370
|
+
|
|
382
371
|
# 清除会话状态并关闭传输和日志资源。
|
|
383
372
|
def close_resources
|
|
384
373
|
@state = :closed
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "expect/pty"
|
|
5
|
+
require_relative "known_hosts"
|
|
5
6
|
|
|
6
7
|
module Net
|
|
7
8
|
module Connector
|
|
@@ -125,6 +126,29 @@ module Net
|
|
|
125
126
|
@legacy_arguments = legacy_arguments.dup.freeze
|
|
126
127
|
end
|
|
127
128
|
|
|
129
|
+
def open
|
|
130
|
+
configuration.validate_endpoint!
|
|
131
|
+
unless configuration.host_key_policy == :strict
|
|
132
|
+
@known_hosts = KnownHosts.new(configuration, replace: @replace_host_key)
|
|
133
|
+
end
|
|
134
|
+
super
|
|
135
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Release a staged trust file on failed startup.
|
|
136
|
+
close
|
|
137
|
+
raise
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def authenticated
|
|
141
|
+
@known_hosts&.commit
|
|
142
|
+
@replace_host_key = false
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def close
|
|
146
|
+
super
|
|
147
|
+
ensure
|
|
148
|
+
@known_hosts&.close
|
|
149
|
+
@known_hosts = nil
|
|
150
|
+
end
|
|
151
|
+
|
|
128
152
|
# 返回 SSH 协议标识。
|
|
129
153
|
def protocol = :ssh
|
|
130
154
|
|
|
@@ -133,9 +157,10 @@ module Net
|
|
|
133
157
|
configuration.validate_endpoint!
|
|
134
158
|
checking = (configuration.host_key_policy == :strict) ? "yes" : "accept-new"
|
|
135
159
|
arguments = ["ssh", *legacy_arguments, "-tt", "-o", "StrictHostKeyChecking=#{checking}",
|
|
136
|
-
"-o", "NumberOfPasswordPrompts=1",
|
|
160
|
+
"-o", "NumberOfPasswordPrompts=1", "-o", "UpdateHostKeys=no",
|
|
137
161
|
"-o", "ConnectTimeout=#{[configuration.login_timeout.ceil, 1].max}"]
|
|
138
|
-
|
|
162
|
+
known_hosts = @known_hosts&.path || configuration.known_hosts
|
|
163
|
+
arguments += ["-o", "UserKnownHostsFile=#{known_hosts}"] if known_hosts
|
|
139
164
|
arguments += ["-p", configuration.port.to_s] if configuration.port
|
|
140
165
|
arguments + ["-l", configuration.username, configuration.host]
|
|
141
166
|
end
|
|
@@ -151,13 +176,13 @@ module Net
|
|
|
151
176
|
Telnet.new(configuration, channel_factory: @channel_factory, terminal_size: @terminal_size)
|
|
152
177
|
end
|
|
153
178
|
|
|
154
|
-
#
|
|
179
|
+
# 恢复只标记下一次会话副本;共享信任文件在认证成功后才更新。
|
|
155
180
|
def replace_host_key
|
|
156
|
-
host
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
raise IOError, "removing device host key failed" unless status.success?
|
|
181
|
+
raise IOError, "host key replacement is not enabled" unless configuration.host_key_policy == :replace
|
|
182
|
+
|
|
183
|
+
@replace_host_key = true
|
|
160
184
|
end
|
|
185
|
+
|
|
161
186
|
end
|
|
162
187
|
|
|
163
188
|
# Telnet 只能显式启用;SSH 专用端口不能带入回退连接。
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
module Netdisco
|
|
9
|
+
# 人类批次入口:固定配置、预览、执行、保存报告,最后决定输出与退出码。
|
|
10
|
+
class BackupRun
|
|
11
|
+
def initialize(mode: :backup, argv: ARGV, env: ENV, input: $stdin, output: $stdout, error: $stderr)
|
|
12
|
+
raise ArgumentError, "mode must be backup or tftp" unless %i[backup tftp].include?(mode)
|
|
13
|
+
|
|
14
|
+
@mode, @argv, @env, @input, @output, @error = mode, argv, env, input, output, error
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
options = CLI::Options.parse(argv: @argv, input: @input, output: @output, error: @error)
|
|
19
|
+
return 0 if options[:help]
|
|
20
|
+
|
|
21
|
+
settings, success_policy, server_root, batch_directory = prepare(options)
|
|
22
|
+
client, credentials = Connection.build(settings, stdin_credentials: options[:stdin_credentials], input: @input)
|
|
23
|
+
progress = Progress.new(io: @error, enabled: @env.fetch("NC_PROGRESS", "1") != "0", verbose: options[:verbose])
|
|
24
|
+
resolver = ->(device) { credentials.call(device)&.merge(on_event: progress.method(:event)) }
|
|
25
|
+
fleet = Fleet.new(client: client, settings: settings, credentials: resolver, result_store: nil)
|
|
26
|
+
progress.reading_inventory
|
|
27
|
+
limit = settings.limit_per_vendor
|
|
28
|
+
plan = if @mode == :backup
|
|
29
|
+
fleet.plan_backup(limit_per_vendor: limit)
|
|
30
|
+
else
|
|
31
|
+
fleet.plan_tftp_backup(limit_per_vendor: limit, allow_fixed_name_reuse: !server_root.nil?)
|
|
32
|
+
end
|
|
33
|
+
progress.plan(plan, concurrency: settings.concurrency, limit_per_vendor: limit)
|
|
34
|
+
preview = plan_summary(plan)
|
|
35
|
+
@output.puts JSON.generate(plan: preview) if options[:json]
|
|
36
|
+
@output.flush
|
|
37
|
+
return 1 if options[:stdin_credentials] && @input.gets&.strip != "RUN"
|
|
38
|
+
|
|
39
|
+
Storage::PrivateFile.write(File.join(batch_directory, "plan.json"), JSON.pretty_generate(preview))
|
|
40
|
+
report = execute(fleet, settings, plan, batch_directory, progress, success_policy, server_root)
|
|
41
|
+
report = Report::Files.write(report, directory: batch_directory, plan: plan, concurrency: settings.concurrency)
|
|
42
|
+
finish(report, progress, options, batch_directory)
|
|
43
|
+
rescue ArgumentError, Client::Error => exception
|
|
44
|
+
@error.puts "备份参数或清单错误:#{exception.message}"
|
|
45
|
+
2
|
|
46
|
+
rescue StandardError => exception
|
|
47
|
+
@error.puts "备份未完成(#{exception.class})"
|
|
48
|
+
2
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def prepare(options)
|
|
54
|
+
settings = CLI::Options.settings(options, env: @env)
|
|
55
|
+
success_policy = options.fetch(:success_policy, :selected)
|
|
56
|
+
raise ArgumentError, "verified policy requires TFTP mode" if success_policy == :verified && @mode != :tftp
|
|
57
|
+
raise ArgumentError, "--tftp-root requires TFTP mode" if options[:tftp_root] && @mode != :tftp
|
|
58
|
+
server_root = tftp_root(settings, options) if @mode == :tftp
|
|
59
|
+
raise ArgumentError, "verified policy requires a local TFTP directory" if success_policy == :verified && !server_root
|
|
60
|
+
TftpVerification.new(root: server_root) if server_root
|
|
61
|
+
batch_directory = Storage::BatchDirectory.create(settings.backup_directory)
|
|
62
|
+
if @mode == :tftp && !settings.log_directory
|
|
63
|
+
settings = settings.with_overrides("NC_LOG_DIRECTORY" => File.join(batch_directory, "logs"))
|
|
64
|
+
end
|
|
65
|
+
settings = settings.for_run(mode: @mode)
|
|
66
|
+
[settings, success_policy, server_root, batch_directory]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def execute(fleet, settings, plan, batch_directory, progress, success_policy, server_root)
|
|
70
|
+
File.open(File.join(batch_directory, "events.jsonl"), File::WRONLY | File::CREAT | File::EXCL, 0o600) do |events|
|
|
71
|
+
options = { plan: plan, concurrency: settings.concurrency, success_policy: success_policy,
|
|
72
|
+
on_start: progress.method(:start), on_result: result_callback(progress, events) }
|
|
73
|
+
progress.with_updates do
|
|
74
|
+
if @mode == :backup
|
|
75
|
+
fleet.backup_all(directory: batch_directory, filename_style: :hostname_ip, **options)
|
|
76
|
+
else
|
|
77
|
+
fleet.tftp_backup_all(server: settings.tftp_server, source_files: settings.tftp_source_files,
|
|
78
|
+
vrfs: settings.tftp_vrfs, report_directory: batch_directory,
|
|
79
|
+
verification_root: server_root, preserve_history: true, **options)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def result_callback(progress, events)
|
|
86
|
+
lock = Mutex.new
|
|
87
|
+
lambda do |outcome|
|
|
88
|
+
progress.result(outcome)
|
|
89
|
+
lock.synchronize do
|
|
90
|
+
events.puts JSON.generate(host: outcome.device.host, vendor: outcome.device.vendor,
|
|
91
|
+
status: outcome.status, error_code: outcome.diagnostic&.error_code,
|
|
92
|
+
error_type: outcome.diagnostic&.error_type, duration_ms: outcome.duration_ms)
|
|
93
|
+
events.flush
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def finish(report, progress, options, batch_directory)
|
|
99
|
+
progress.finish(report)
|
|
100
|
+
progress.location(report.report_location) if report.report_location
|
|
101
|
+
@error.puts "文本报告:#{File.join(batch_directory, "summary.txt")}" if @env.fetch("NC_PROGRESS", "1") != "0" && File.file?(File.join(batch_directory, "summary.txt"))
|
|
102
|
+
if options[:json]
|
|
103
|
+
@output.puts JSON.generate(directory: batch_directory, counts: report.counts,
|
|
104
|
+
tasks_succeeded: report.policy_success?, success: report.policy_success?,
|
|
105
|
+
policy: report.policy, policy_success: report.policy_success?,
|
|
106
|
+
report_location: report.report_location, report_error: report.report_error)
|
|
107
|
+
end
|
|
108
|
+
report.policy_success? ? 0 : 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def tftp_root(settings, options)
|
|
112
|
+
server = settings.tftp_server || (raise ArgumentError, "TFTP_HOST or YAML tftp.server is required")
|
|
113
|
+
return options[:tftp_root] || @env["TFTP_ROOT"] if options[:tftp_root] || @env["TFTP_ROOT"]
|
|
114
|
+
return unless Socket.ip_address_list.any? { |address| address.ip_address == server }
|
|
115
|
+
|
|
116
|
+
candidate = File.join(Dir.home, "Documents", "TFTP")
|
|
117
|
+
candidate if File.directory?(candidate)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def plan_summary(plan)
|
|
121
|
+
{ total: plan.inventory.size, ready: plan.inventory.count(&:ready?),
|
|
122
|
+
vendors: plan.inventory.group_by { |device| device.vendor || :unmapped }.transform_values(&:size),
|
|
123
|
+
issues: plan.inventory.reject(&:ready?).group_by(&:issue).transform_values(&:size),
|
|
124
|
+
selected: plan.selected.map do |device|
|
|
125
|
+
filename = @mode == :backup ? device.backup_filename(style: :hostname_ip) : device.tftp_filename
|
|
126
|
+
{ host: device.host, name: device.name, vendor: device.vendor, backup_filename: filename }
|
|
127
|
+
end }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -45,7 +45,7 @@ module Net
|
|
|
45
45
|
Batch = Data.define(:mode, :outcomes, :started_at, :finished_at, :callback_errors,
|
|
46
46
|
:report_location, :report_error) do
|
|
47
47
|
# 按设备最终状态统计数量。
|
|
48
|
-
def counts = outcomes.
|
|
48
|
+
def counts = outcomes.map(&:status).tally
|
|
49
49
|
|
|
50
50
|
# 仅在所有设备成功且回调、报告均正常时判定整批成功。
|
|
51
51
|
def success? = !outcomes.empty? && outcomes.all?(&:success?) && callback_errors.empty? && report_error.nil?
|