net-connector 0.7.3 → 0.9.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 +21 -1
- data/CONTRIBUTING.md +12 -0
- data/README.md +27 -424
- data/README.zh-CN.md +64 -0
- data/{exe/net-connector-backup → bin/net-backup} +1 -0
- data/docs/VERIFICATION.md +2 -2
- data/docs/architecture.md +27 -5
- data/docs/netdisco.md +176 -0
- data/docs/netdisco.zh-CN.md +247 -0
- data/docs/usage.md +124 -0
- data/docs/usage.zh-CN.md +190 -0
- data/examples/backup.yml +5 -4
- data/examples/inventory_sql.yml +5 -1
- data/lib/net/connector/device/base.rb +12 -5
- data/lib/net/connector/device/local_backup.rb +3 -0
- data/lib/net/connector/device/running_config/strategy.rb +2 -1
- data/lib/net/connector/device/running_config.rb +2 -0
- data/lib/net/connector/device/tftp/receipt.rb +1 -0
- data/lib/net/connector/device/tftp/strategy.rb +28 -9
- data/lib/net/connector/device/tftp.rb +23 -16
- data/lib/net/connector/device/topology/immediate_strategy.rb +2 -1
- data/lib/net/connector/engine/authentication.rb +1 -0
- data/lib/net/connector/engine/core.rb +1 -0
- data/lib/net/connector/engine/dialogue.rb +5 -134
- data/lib/net/connector/engine/errors.rb +0 -92
- data/lib/net/connector/engine/known_hosts.rb +20 -4
- data/lib/net/connector/engine/log.rb +27 -6
- data/lib/net/connector/engine/recovery.rb +1 -1
- data/lib/net/connector/engine/redactor.rb +98 -0
- data/lib/net/connector/engine/response_reader.rb +139 -0
- data/lib/net/connector/engine/session.rb +20 -7
- data/lib/net/connector/engine/transport.rb +21 -10
- data/lib/net/connector/netdisco/backup_run.rb +35 -8
- data/lib/net/connector/netdisco/cli/options.rb +20 -8
- data/lib/net/connector/netdisco/cli.rb +14 -11
- data/lib/net/connector/netdisco/config_file.rb +19 -14
- data/lib/net/connector/netdisco/connection.rb +0 -1
- data/lib/net/connector/netdisco/database_client.rb +44 -26
- data/lib/net/connector/netdisco/device.rb +5 -0
- data/lib/net/connector/netdisco/diagnostic.rb +8 -0
- data/lib/net/connector/netdisco/fleet.rb +12 -3
- data/lib/net/connector/netdisco/plan.rb +16 -3
- data/lib/net/connector/netdisco/planner.rb +2 -1
- data/lib/net/connector/netdisco/progress.rb +31 -7
- data/lib/net/connector/netdisco/report/files.rb +4 -2
- data/lib/net/connector/netdisco/result_store.rb +6 -7
- data/lib/net/connector/netdisco/rules.rb +8 -5
- data/lib/net/connector/netdisco/settings.rb +83 -19
- data/lib/net/connector/netdisco/tftp_archive.rb +6 -3
- data/lib/net/connector/netdisco/tftp_verification.rb +1 -0
- data/lib/net/connector/netdisco/worker.rb +3 -1
- data/lib/net/connector/netdisco.rb +1 -21
- data/lib/net/connector/storage/private_file.rb +6 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/cisco_ios/topology.rb +2 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +1 -0
- data/lib/net/connector/vendor/h3c/topology.rb +2 -0
- data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/hillstone/topology.rb +2 -0
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +2 -1
- data/lib/net/connector/vendor/palo_alto/running_config.rb +27 -6
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/palo_alto/topology.rb +16 -16
- data/lib/net/connector/vendor/radware/running_config.rb +1 -0
- data/lib/net/connector/vendor/radware/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/radware/topology.rb +1 -0
- data/lib/net/connector/version.rb +2 -1
- data/lib/net/connector.rb +1 -0
- data/lib/net-connector.rb +1 -0
- metadata +12 -20
- data/lib/net/connector/netdisco/tftp_history.rb +0 -16
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "English"
|
|
4
3
|
require "forwardable"
|
|
5
4
|
require_relative "../engine/core"
|
|
6
5
|
require_relative "profile"
|
|
@@ -67,12 +66,14 @@ module Net
|
|
|
67
66
|
begin
|
|
68
67
|
device.connect
|
|
69
68
|
yield device
|
|
69
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 本次连接或块失败时仍关闭设备,并保留原始故障或中断。
|
|
70
|
+
failed = true
|
|
71
|
+
raise
|
|
70
72
|
ensure
|
|
71
|
-
active_error = $ERROR_INFO
|
|
72
73
|
begin
|
|
73
74
|
device.close
|
|
74
|
-
rescue
|
|
75
|
-
raise unless
|
|
75
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 只保留本次作用域的原始异常,不受外层已处理异常影响。
|
|
76
|
+
raise unless failed
|
|
76
77
|
end
|
|
77
78
|
end
|
|
78
79
|
end
|
|
@@ -249,17 +250,23 @@ module Net
|
|
|
249
250
|
|
|
250
251
|
execution = build_execution(operation: operation, prompt: prompt, after_command: after_command, privilege: privilege)
|
|
251
252
|
output_sensitive = script.any?(&:output_sensitive?)
|
|
253
|
+
result = nil
|
|
252
254
|
@session.perform(:script) do
|
|
253
255
|
@session.log_script(operation: operation, steps: execution.steps) do
|
|
254
256
|
@session.with_sensitive_output(output_sensitive) { before_batch(execution) }
|
|
255
257
|
result = execution.execute_script(script, &on_step)
|
|
256
258
|
sensitive_result = output_sensitive || script.any?(&:sensitive?) || execution.sensitive?
|
|
257
259
|
@session.with_sensitive_output(sensitive_result) do
|
|
258
|
-
finalize_script_result(result, finalize)
|
|
260
|
+
result = finalize_script_result(result, finalize)
|
|
259
261
|
end
|
|
260
262
|
end
|
|
261
263
|
end
|
|
262
264
|
rescue Error => error
|
|
265
|
+
# 脚本完成后的日志收尾仍可能失败,已经生成的配置与主错误保持权威。
|
|
266
|
+
if result.is_a?(Result)
|
|
267
|
+
return Result.new(steps: result.steps, config: result.config, error: result.error || error)
|
|
268
|
+
end
|
|
269
|
+
|
|
263
270
|
Result.new(steps: execution ? execution.steps : [], error: error)
|
|
264
271
|
end
|
|
265
272
|
|
|
@@ -39,6 +39,7 @@ module Net
|
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# 先取得路径锁再采集和原子写入;提交后的收尾错误仍保留备份元数据。
|
|
42
43
|
class LocalBackup
|
|
43
44
|
# 设备入口与功能实现共置,Base 只组合能力,不重复业务流程。
|
|
44
45
|
module Capability
|
|
@@ -77,12 +78,14 @@ module Net
|
|
|
77
78
|
backup = Backup.new(path: destination, bytes: contents.bytesize,
|
|
78
79
|
sha256: digest, collected_at: Time.now.utc, change: change,
|
|
79
80
|
previous_sha256: previous_digest)
|
|
81
|
+
# 内容未变也须核对权限与目录项身份;只在两者仍有效时保留原文件和 mtime。
|
|
80
82
|
if change != :unchanged || previous.mode != 0o600 || !Storage::SafeFile.same_entry?(destination, previous)
|
|
81
83
|
write_backup(backup, contents)
|
|
82
84
|
end
|
|
83
85
|
backup
|
|
84
86
|
end
|
|
85
87
|
|
|
88
|
+
# 替换后失败仍交付已提交的备份回执,不能让调用方误判为没有产物。
|
|
86
89
|
def write_backup(backup, contents)
|
|
87
90
|
Storage::PrivateFile.write(backup.path, contents)
|
|
88
91
|
rescue Storage::PrivateFile::WriteError => error
|
|
@@ -5,6 +5,7 @@ require_relative "../../engine/terminal_renderer"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
class RunningConfig
|
|
8
|
+
# 采集策略决定业务步骤、视图提示符和响应校验;默认不改写配置正文。
|
|
8
9
|
class Strategy
|
|
9
10
|
# 保存策略所属设备,供提示符和厂商校验使用。
|
|
10
11
|
def initialize(device) = @device = device
|
|
@@ -24,7 +25,7 @@ module Net
|
|
|
24
25
|
def validate_response!(_command, _response, _execution) end
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
#
|
|
28
|
+
# 按终端行编辑规则还原配置画面,厂商子类只清理已识别的响应头。
|
|
28
29
|
class Rendered < Strategy
|
|
29
30
|
# 应用终端行编辑规则,还原设备最终显示的配置文本。
|
|
30
31
|
def clean(text) = TerminalRenderer.render(text)
|
|
@@ -6,6 +6,7 @@ require_relative "running_config/strategy"
|
|
|
6
6
|
|
|
7
7
|
module Net
|
|
8
8
|
module Connector
|
|
9
|
+
# 单次运行配置采集的协调器;策略状态仅在当前会话租约与 Fiber 内可见。
|
|
9
10
|
class RunningConfig
|
|
10
11
|
# 设备只引入此能力;采集入口和策略作用域与采集流程一起维护。
|
|
11
12
|
module Capability
|
|
@@ -40,6 +41,7 @@ module Net
|
|
|
40
41
|
@config_strategy_scope = previous
|
|
41
42
|
end
|
|
42
43
|
|
|
44
|
+
# 离线清理使用新策略;只有当前 Fiber 可复用正在采集的策略状态。
|
|
43
45
|
def config_strategy
|
|
44
46
|
scope = @config_strategy_scope
|
|
45
47
|
scope && scope.first.equal?(Fiber.current) ? scope.last : RunningConfig.strategy(self)
|
|
@@ -15,6 +15,7 @@ module Net
|
|
|
15
15
|
FORMATS = %i[cfg xml dat tgz unknown].freeze
|
|
16
16
|
private_constant :KINDS, :FORMATS
|
|
17
17
|
|
|
18
|
+
# 校验目标、配置种类和核验状态,只有服务器核验成功才允许携带文件摘要与归档路径。
|
|
18
19
|
def initialize(server:, path:, completed_at:, configuration_kind: :unknown, source_file: nil,
|
|
19
20
|
format: :unknown, requested_path: nil, verification: :device_reported, server_sha256: nil,
|
|
20
21
|
server_bytes: nil, local_path: nil, archive_path: nil)
|
|
@@ -5,8 +5,9 @@ require_relative "target"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
class Tftp
|
|
8
|
+
# 厂商声明命名、交互与完成证据;公共层统一过滤精确回显和保留失败历史。
|
|
8
9
|
class Strategy
|
|
9
|
-
#
|
|
10
|
+
# 声明默认配置扩展名,清单规划与设备直连共用同一命名规则。
|
|
10
11
|
def self.file_extension = "cfg"
|
|
11
12
|
|
|
12
13
|
# 不创建会话即可生成目标名,供批量计划检查文件覆盖风险。
|
|
@@ -15,10 +16,7 @@ module Net
|
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
# 保存 TFTP 厂商策略使用的设备对象。
|
|
18
|
-
def initialize(device)
|
|
19
|
-
@device = device
|
|
20
|
-
@responses = []
|
|
21
|
-
end
|
|
19
|
+
def initialize(device) = @device = device
|
|
22
20
|
|
|
23
21
|
# 返回调用方指定的配置源文件。
|
|
24
22
|
def resolve_source_file(value) = value
|
|
@@ -29,11 +27,21 @@ module Net
|
|
|
29
27
|
# 返回目标中指定的远端文件名。
|
|
30
28
|
def remote_path(target, _result) = target.path
|
|
31
29
|
|
|
30
|
+
# 原文、历史和终端画面共同保存失败证据;仅过滤精确输入回显与提示符。
|
|
31
|
+
def failure_outputs(result)
|
|
32
|
+
return enum_for(__method__, result) unless block_given?
|
|
33
|
+
|
|
34
|
+
result.steps.each do |step|
|
|
35
|
+
yield evidence_lines(step, step.output).join("\n")
|
|
36
|
+
yield evidence_lines(step, TerminalRenderer.render(step.output.gsub(/\r(?!\n)/, "\n"))).join("\n")
|
|
37
|
+
yield evidence_lines(step, TerminalRenderer.render(step.output)).join("\n")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
32
41
|
private
|
|
33
42
|
|
|
34
|
-
#
|
|
43
|
+
# 静态交互不进入业务输出,证据过滤从完成步骤读取原始声明。
|
|
35
44
|
def interaction(pattern, response)
|
|
36
|
-
@responses << response.chomp
|
|
37
45
|
Interaction.new(pattern, response, capture: false)
|
|
38
46
|
end
|
|
39
47
|
|
|
@@ -45,8 +53,19 @@ module Net
|
|
|
45
53
|
output = TerminalRenderer.render(step.output)
|
|
46
54
|
prompt = TerminalRenderer.render(step.prompt.to_s)
|
|
47
55
|
output = output.delete_suffix(prompt) unless prompt.empty?
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
evidence_lines(step, output)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 移除精确命令、静态响应和提示符,保留回车重绘前后的设备消息。
|
|
60
|
+
def evidence_lines(step, output)
|
|
61
|
+
prompt = TerminalRenderer.render(step.prompt.to_s).strip
|
|
62
|
+
responses = step.command.interactions.filter_map { |reply| reply.static_response&.chomp }
|
|
63
|
+
echoes = [step.command.text, "#{prompt}#{step.command.text}", *responses]
|
|
64
|
+
# 原文中的每次回车分开核对,避免重绘回显误报,也保留被覆盖的诊断。
|
|
65
|
+
output.each_line.flat_map { |line| line.split(/\r(?!\n)/) }.map do |line|
|
|
66
|
+
# 样式不改变字符历史;光标移动和擦除不能拿来删掉原始失败消息。
|
|
67
|
+
line.gsub(/\e\[[0-9;:]*m/, "").strip
|
|
68
|
+
end.reject { |line| line.empty? || line == prompt || echoes.include?(line) }
|
|
50
69
|
end
|
|
51
70
|
|
|
52
71
|
# 只接受字节数非零、已发送量与总量相同的 curl 完成进度行。
|
|
@@ -5,6 +5,7 @@ require_relative "tftp/receipt"
|
|
|
5
5
|
|
|
6
6
|
module Net
|
|
7
7
|
module Connector
|
|
8
|
+
# 协调原生上传与设备证据核对;设备报告完成和服务器文件核验分别处理。
|
|
8
9
|
class Tftp
|
|
9
10
|
# 设备入口与功能实现共置,Base 只组合能力,不重复业务流程。
|
|
10
11
|
module Capability
|
|
@@ -66,6 +67,7 @@ module Net
|
|
|
66
67
|
|
|
67
68
|
private
|
|
68
69
|
|
|
70
|
+
# 只接受厂商声明的固定元数据字段,沿用已确认的上传与路径回执。
|
|
69
71
|
def build_receipt(strategy, target, source_file, explicit_source, receipt)
|
|
70
72
|
metadata = strategy.receipt_metadata(target, source_file: source_file, explicit_source: explicit_source)
|
|
71
73
|
unless metadata.is_a?(Hash) && (metadata.keys - %i[configuration_kind source_file format requested_path]).empty?
|
|
@@ -74,6 +76,7 @@ module Net
|
|
|
74
76
|
receipt.with(**{ source_file: source_file, requested_path: target.path }.merge(metadata))
|
|
75
77
|
end
|
|
76
78
|
|
|
79
|
+
# 实际目标必须重新校验;解析失败保留上传事实,但不推测远端路径。
|
|
77
80
|
def remote_path(strategy, target, result, receipt)
|
|
78
81
|
TftpTarget.validate_path!(strategy.remote_path(target, result))
|
|
79
82
|
rescue StandardError => error
|
|
@@ -83,33 +86,37 @@ module Net
|
|
|
83
86
|
|
|
84
87
|
# 失败证据优先;步骤中的明确完成行可在随后清理失败时证明设备已经报告上传。
|
|
85
88
|
def confirm_transfer!(strategy, target, result)
|
|
86
|
-
if transfer_failed?(result)
|
|
87
|
-
|
|
88
|
-
code: :transfer_failed, server: target.host)
|
|
89
|
-
raise DeviceError.new("device reported TFTP backup failure",
|
|
90
|
-
code: :transfer_failed, host: @device.host, phase: :tftp_backup)
|
|
89
|
+
if transfer_failed?(strategy, result)
|
|
90
|
+
reject_transfer!(target, :transfer_failed, "device reported TFTP backup failure")
|
|
91
91
|
end
|
|
92
92
|
return if strategy.device_reported_complete?(result)
|
|
93
93
|
|
|
94
94
|
result.value! if result.failure?
|
|
95
|
-
|
|
96
|
-
code: :transfer_unconfirmed, server: target.host)
|
|
97
|
-
raise DeviceError.new("device did not confirm TFTP backup completion",
|
|
98
|
-
code: :transfer_unconfirmed, host: @device.host, phase: :tftp_backup)
|
|
95
|
+
reject_transfer!(target, :transfer_unconfirmed, "device did not confirm TFTP backup completion")
|
|
99
96
|
end
|
|
100
97
|
|
|
101
|
-
#
|
|
102
|
-
def
|
|
103
|
-
|
|
98
|
+
# 设备证据先决定业务失败;事件记录失败不能把明确的上传状态替换为日志故障。
|
|
99
|
+
def reject_transfer!(target, code, message)
|
|
100
|
+
failure = DeviceError.new(message, code: code, host: @device.host, phase: :tftp_backup)
|
|
101
|
+
begin
|
|
102
|
+
@device.log_event("tftp_backup", level: :error, status: code.to_s, phase: :tftp_backup,
|
|
103
|
+
code: code, server: target.host)
|
|
104
|
+
rescue Error
|
|
105
|
+
# 主错误仍在下方抛出,不重试事件或设备命令。
|
|
106
|
+
end
|
|
107
|
+
raise failure, cause: nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# 已有厂商策略可提供证据枚举;其他策略复用公共证据过滤,不重跑交互回调。
|
|
111
|
+
def transfer_failed?(strategy, result)
|
|
112
|
+
evidence = strategy.respond_to?(:failure_outputs) ? strategy : Strategy.new(@device)
|
|
113
|
+
evidence.failure_outputs(result).any? do |output|
|
|
104
114
|
# 文件名中的 failed.cfg、error-backup 等不属于诊断词;句末标点仍是词边界。
|
|
105
115
|
pattern = %r{(?<![\w./-])
|
|
106
116
|
(?:error|failed|failure|aborted|denied|timed\ out|timeout|not\ found|no\ such\ file|
|
|
107
117
|
unable\ to|network\ is\ unreachable|can't\ open)
|
|
108
118
|
(?![\w/-]|\.[\w])}ix
|
|
109
|
-
|
|
110
|
-
history = step.output.gsub(/\r(?!\n)/, "\n")
|
|
111
|
-
step.output.match?(pattern) || TerminalRenderer.render(history).match?(pattern) ||
|
|
112
|
-
TerminalRenderer.render(step.output).match?(pattern)
|
|
119
|
+
output.match?(pattern)
|
|
113
120
|
end
|
|
114
121
|
end
|
|
115
122
|
end
|
|
@@ -24,9 +24,10 @@ module Net
|
|
|
24
24
|
def persistence_confirmed?(result)
|
|
25
25
|
!result.steps.empty? && result.steps.all? do |step|
|
|
26
26
|
raw = step.output.delete_suffix(step.prompt.to_s)
|
|
27
|
+
history = raw.gsub(/\e\[[0-9;:]*m/, "").gsub(/\r(?!\n)/, "\n")
|
|
27
28
|
rendered = TerminalRenderer.render(raw)
|
|
28
29
|
failure = /\b(?:error|failed|failure|denied|cannot|unable|unsuccessful|aborted)\b/i
|
|
29
|
-
next false if
|
|
30
|
+
next false if history.match?(failure) || TerminalRenderer.render(history).match?(failure) || rendered.match?(failure)
|
|
30
31
|
|
|
31
32
|
rendered.lines.any? { |line| line.strip.match?(persistence_pattern) }
|
|
32
33
|
end
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
4
|
require_relative "terminal_renderer"
|
|
5
|
-
require "expect/pty"
|
|
6
5
|
|
|
7
6
|
module Net
|
|
8
7
|
module Connector
|
|
@@ -37,6 +36,9 @@ module Net
|
|
|
37
36
|
# 根据当前提示计算响应文本或调用动态响应函数。
|
|
38
37
|
def response(prompt) = @response.respond_to?(:call) ? @response.call(prompt) : @response
|
|
39
38
|
|
|
39
|
+
# 证据核对只能读取声明的静态响应,不能重新执行动态挑战回调。
|
|
40
|
+
def static_response = (@response if @response.is_a?(String))
|
|
41
|
+
|
|
40
42
|
# 判断响应是否需要从日志和错误上下文中脱敏。
|
|
41
43
|
def sensitive? = @sensitive
|
|
42
44
|
|
|
@@ -79,8 +81,8 @@ module Net
|
|
|
79
81
|
def diagnostic(output)
|
|
80
82
|
return if command_errors.empty?
|
|
81
83
|
|
|
82
|
-
#
|
|
83
|
-
raw = output.gsub(/\r(?!\n)/, "\n")
|
|
84
|
+
# 样式不改变诊断历史;先拼回被颜色拆开的词,再保留重绘之前的失败。
|
|
85
|
+
raw = output.gsub(/\e\[[0-9;:]*m/, "").gsub(/\r(?!\n)/, "\n")
|
|
84
86
|
diagnostic_line(raw) || (diagnostic_line(TerminalRenderer.render(output)) if output.match?(/[\e\b]/))
|
|
85
87
|
end
|
|
86
88
|
|
|
@@ -113,136 +115,5 @@ module Net
|
|
|
113
115
|
# 返回响应大小摘要,不展开原始内容。
|
|
114
116
|
def inspect = "#<#{self.class} bytes=#{raw.bytesize}>"
|
|
115
117
|
end
|
|
116
|
-
|
|
117
|
-
# 在传输层上执行有界对话;收到字节或分页标记不会重置截止时间。
|
|
118
|
-
class ResponseReader
|
|
119
|
-
# 保存会话引用,以便读取器使用传输、配置和脱敏上下文。
|
|
120
|
-
def initialize(session)
|
|
121
|
-
@session = session
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# 读取到结束提示、失败模式或超时,并处理交互响应和输出上限。
|
|
125
|
-
def read(prompt:, interactions:, phase:, timeout: nil, deadline: nil, command: nil, failures: {})
|
|
126
|
-
deadline ||= Expect.monotonic + timeout
|
|
127
|
-
patterns = [*failures.keys, *interactions.map(&:pattern), prompt]
|
|
128
|
-
raw = []
|
|
129
|
-
output = []
|
|
130
|
-
counts = Hash.new(0)
|
|
131
|
-
bytes = 0
|
|
132
|
-
loop do
|
|
133
|
-
event = read_event(patterns, deadline)
|
|
134
|
-
raw << event.before unless event.before.empty?
|
|
135
|
-
output << event.before unless event.before.empty?
|
|
136
|
-
bytes += event.before.bytesize + event.match.bytesize
|
|
137
|
-
validate_event!(event, bytes, raw, phase, command)
|
|
138
|
-
|
|
139
|
-
if event.index == patterns.size
|
|
140
|
-
raw << event.match
|
|
141
|
-
output << event.match
|
|
142
|
-
elsif event.index == patterns.size - 1
|
|
143
|
-
return finish_response(event.match, raw, output, phase, command)
|
|
144
|
-
elsif event.index < failures.size
|
|
145
|
-
raise build_connection_error(failures.values.fetch(event.index), phase, command), cause: nil
|
|
146
|
-
else
|
|
147
|
-
reply = interactions.fetch(event.index - failures.size)
|
|
148
|
-
raw << event.match
|
|
149
|
-
output << event.match if reply.capture?
|
|
150
|
-
respond(reply, event.match, counts, deadline, phase, command)
|
|
151
|
-
end
|
|
152
|
-
next if Expect.monotonic < deadline
|
|
153
|
-
|
|
154
|
-
raise build_read_error(nil, phase, command, raw.join), cause: nil
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
private
|
|
159
|
-
|
|
160
|
-
# 分页和流式输出只消耗剩余时间,不为下一次读取重新计时。
|
|
161
|
-
def read_event(patterns, deadline)
|
|
162
|
-
@session.transport.read(patterns, timeout: [deadline - Expect.monotonic, 0].max)
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
# 连接失败模式携带稳定错误码,供连接级恢复规则选择处理方式。
|
|
166
|
-
def build_connection_error(failure, phase, command)
|
|
167
|
-
klass, code = failure
|
|
168
|
-
@session.build_error(klass, "device connection failed (#{code})", phase: phase, code: code, command: command)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# 先限制原始字节数,再处理传输失败,避免错误输出绕过总量限制。
|
|
172
|
-
def validate_event!(event, bytes, raw, phase, command)
|
|
173
|
-
if bytes > @session.configuration.max_output_bytes
|
|
174
|
-
raise @session.build_error(OutputLimitExceeded, "device output exceeded max_output_bytes",
|
|
175
|
-
phase: phase, command: command, output: (raw + [event.match]).join), cause: nil
|
|
176
|
-
end
|
|
177
|
-
raise build_read_error(event, phase, command, raw.join), cause: nil unless event.matched?
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# 完整提示符必须消费字节;交互标记可从业务输出排除,但原始响应始终保留。
|
|
181
|
-
def finish_response(prompt, raw, output, phase, command)
|
|
182
|
-
if prompt.empty?
|
|
183
|
-
raise @session.build_error(PromptError, "prompt pattern did not consume any output",
|
|
184
|
-
phase: phase, command: command, output: raw.join), cause: nil
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
raw << prompt
|
|
188
|
-
output << prompt
|
|
189
|
-
raw_text = raw.join
|
|
190
|
-
Response.new(raw: raw_text, output: (output == raw) ? raw_text : output.join, prompt: prompt)
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
# 重复提示和缺失应答必须在写入前失败;所有应答共用读取操作的截止时间。
|
|
194
|
-
def respond(reply, prompt, counts, deadline, phase, command)
|
|
195
|
-
counts[reply] += 1
|
|
196
|
-
if prompt.empty? || (reply.limit && counts[reply] > reply.limit)
|
|
197
|
-
raise build_interaction_error("response rejected or repeated", phase, command), cause: nil
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
value = interaction_response(reply, prompt, phase, command)
|
|
201
|
-
raise build_interaction_error("response unavailable", phase, command), cause: nil unless value.is_a?(String)
|
|
202
|
-
|
|
203
|
-
@session.redactor.remember(value.chomp) if reply.sensitive?
|
|
204
|
-
@session.write(value, deadline: deadline, phase: phase, command: command)
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
# 动态口令尚未返回时无法加入词表,须在敏感范围内先归一化回调异常。
|
|
208
|
-
# 成功后由调用方登记响应,临时敏感标记不会影响后续普通命令的诊断。
|
|
209
|
-
def interaction_response(reply, prompt, phase, command)
|
|
210
|
-
return reply.response(prompt) unless reply.sensitive?
|
|
211
|
-
|
|
212
|
-
@session.redactor.with_scope(reuse: true) do
|
|
213
|
-
@session.redactor.sensitive!
|
|
214
|
-
reply.response(prompt)
|
|
215
|
-
rescue => error
|
|
216
|
-
raise @session.normalize_error(error, phase: phase, command: command), cause: nil
|
|
217
|
-
end
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
# 将交互拒绝或响应缺失转换为认证或脚本错误。
|
|
221
|
-
def build_interaction_error(message, phase, command)
|
|
222
|
-
authenticating = %i[login enable].include?(phase)
|
|
223
|
-
klass = authenticating ? AuthenticationError : ScriptError
|
|
224
|
-
label = authenticating ? "authentication" : "command interaction"
|
|
225
|
-
@session.build_error(klass, "#{label} #{message}", phase: phase, command: command)
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
# 根据 EOF、提示符、阶段和底层异常选择具体读取错误类型。
|
|
229
|
-
def build_read_error(event, phase, command, output)
|
|
230
|
-
if event&.error.is_a?(Exception)
|
|
231
|
-
return @session.build_error(TransportError, "transport read failed", phase: phase, command: command,
|
|
232
|
-
underlying: event.error, output: output)
|
|
233
|
-
end
|
|
234
|
-
klass = if event&.error == :eof
|
|
235
|
-
ConnectionClosed
|
|
236
|
-
elsif command&.prompt
|
|
237
|
-
PromptError
|
|
238
|
-
elsif %i[login enable].include?(phase)
|
|
239
|
-
LoginTimeout
|
|
240
|
-
else
|
|
241
|
-
CommandTimeout
|
|
242
|
-
end
|
|
243
|
-
@session.build_error(klass, "device response #{event&.error || :timeout}", phase: phase,
|
|
244
|
-
command: command, output: output)
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
118
|
end
|
|
248
119
|
end
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "expect/redactor"
|
|
4
3
|
require_relative "error_metadata"
|
|
5
4
|
|
|
6
5
|
module Net
|
|
@@ -89,96 +88,5 @@ module Net
|
|
|
89
88
|
# 返回底层异常类型摘要。
|
|
90
89
|
def inspect = "#<#{self.class} type=#{type}>"
|
|
91
90
|
end
|
|
92
|
-
|
|
93
|
-
# 管理连接器的秘密作用域和输出敏感性;字节过滤统一委托给 expect-pty。
|
|
94
|
-
class Redactor
|
|
95
|
-
MASK = "[REDACTED]".b.freeze
|
|
96
|
-
|
|
97
|
-
# 保存初始凭据,并建立第一版脱敏词表。
|
|
98
|
-
def initialize(*secrets)
|
|
99
|
-
unless Expect.constants(false).include?(:Redactor) && Expect::Redactor.respond_to?(:redact)
|
|
100
|
-
raise LoadError, "net-connector requires expect-pty with the public Expect::Redactor API"
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
@configured = secrets.compact.map(&:b).map(&:freeze).freeze
|
|
104
|
-
reset
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
# 重置动态词表,同时保留配置中的固定凭据。
|
|
108
|
-
def reset
|
|
109
|
-
@secrets = []
|
|
110
|
-
@patterns = nil
|
|
111
|
-
@scoped = false
|
|
112
|
-
@sensitive = false
|
|
113
|
-
@output_sensitive = false
|
|
114
|
-
@configured.each { |secret| remember(secret) }
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# 临时命令响应在错误归一化期间保留,离开作用域后不进入长期会话。
|
|
118
|
-
def with_scope(reuse: false)
|
|
119
|
-
return yield if reuse && @scoped
|
|
120
|
-
|
|
121
|
-
previous = @secrets
|
|
122
|
-
previous_patterns = @patterns
|
|
123
|
-
previous_scoped = @scoped
|
|
124
|
-
previous_sensitive = @sensitive
|
|
125
|
-
previous_output_sensitive = @output_sensitive
|
|
126
|
-
@secrets = previous.dup
|
|
127
|
-
@scoped = true
|
|
128
|
-
begin
|
|
129
|
-
yield
|
|
130
|
-
ensure
|
|
131
|
-
@secrets = previous
|
|
132
|
-
@patterns = previous_patterns
|
|
133
|
-
@scoped = previous_scoped
|
|
134
|
-
@sensitive = previous_sensitive
|
|
135
|
-
@output_sensitive = previous_output_sensitive
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
# 敏感命令和交互的异常可能只引用秘密的一部分,不能只依赖完整词匹配。
|
|
140
|
-
def sensitive! = @sensitive = true
|
|
141
|
-
|
|
142
|
-
# 判断当前脱敏范围是否包含敏感交互。
|
|
143
|
-
def sensitive? = @sensitive
|
|
144
|
-
|
|
145
|
-
# 输出敏感时不把正文加入词表;只在当前范围内屏蔽日志和任意错误正文。
|
|
146
|
-
def output_sensitive!
|
|
147
|
-
@output_sensitive = true
|
|
148
|
-
sensitive!
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
# 后续查询继承输出边界,即使查询命令本身没有敏感标记。
|
|
152
|
-
def output_sensitive? = @output_sensitive
|
|
153
|
-
|
|
154
|
-
# 只维护当前作用域的注册词;匹配次序和重叠区间交给共享过滤器。
|
|
155
|
-
def remember(secret)
|
|
156
|
-
return if secret.nil? || secret.empty?
|
|
157
|
-
|
|
158
|
-
bytes = secret.b
|
|
159
|
-
return if @secrets.include?(bytes)
|
|
160
|
-
|
|
161
|
-
@secrets << bytes.freeze
|
|
162
|
-
@patterns = nil
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
# 一次性诊断不共享流状态;原标记作为不透明区间,避免再次展开它。
|
|
166
|
-
def call(text)
|
|
167
|
-
Expect::Redactor.redact(text.to_s.b, patterns, replacement: MASK)
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
# 每个日志目标独占过滤流,跨分片尾部和重叠掩码由依赖库维护。
|
|
171
|
-
def stream
|
|
172
|
-
Expect::Redactor.new(patterns, replacement: MASK)
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
# 临时范围恢复时,日志流可以同步恢复注册词表,不缓存配置正文。
|
|
176
|
-
def patterns
|
|
177
|
-
@patterns ||= (@secrets.empty? ? [] : (@secrets + [MASK]).uniq).freeze
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# 返回脱敏器类型摘要。
|
|
181
|
-
def inspect = "#<#{self.class}>"
|
|
182
|
-
end
|
|
183
91
|
end
|
|
184
92
|
end
|
|
@@ -25,8 +25,8 @@ module Net
|
|
|
25
25
|
@file.write(@original)
|
|
26
26
|
@file.flush
|
|
27
27
|
remove_host(@path) if @replace
|
|
28
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
29
|
-
|
|
28
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 中断时也删除私有快照。
|
|
29
|
+
close_preserving_failure
|
|
30
30
|
raise
|
|
31
31
|
end
|
|
32
32
|
|
|
@@ -57,13 +57,29 @@ module Net
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def close
|
|
60
|
-
|
|
61
|
-
@file&.close!
|
|
60
|
+
file = @file
|
|
62
61
|
@file = nil
|
|
62
|
+
File.unlink(@path + ".old") if @path && File.exist?(@path + ".old")
|
|
63
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 删除附属文件失败或中断后仍须关闭私有快照。
|
|
64
|
+
failed = true
|
|
65
|
+
raise
|
|
66
|
+
ensure
|
|
67
|
+
begin
|
|
68
|
+
file&.close!
|
|
69
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 私有快照清理不能覆盖先发生的删除故障。
|
|
70
|
+
raise unless failed
|
|
71
|
+
end
|
|
63
72
|
end
|
|
64
73
|
|
|
65
74
|
private
|
|
66
75
|
|
|
76
|
+
# 初始化失败时保留原始异常,同时尽力清理已经创建的临时文件。
|
|
77
|
+
def close_preserving_failure
|
|
78
|
+
close
|
|
79
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 不让清理异常覆盖初始化故障或用户中断。
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
67
83
|
def read_shared
|
|
68
84
|
File.open(@destination, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
69
85
|
raise IOError, "known_hosts must be a regular file" unless file.stat.file?
|