net-connector 0.4.1 → 0.4.2
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 +34 -0
- data/CONTRIBUTING.md +40 -0
- data/README.md +61 -9
- data/SECURITY.md +11 -0
- data/docs/VERIFICATION.md +129 -6
- data/docs/architecture.md +184 -7
- data/lib/net/connector/device/base.rb +11 -4
- data/lib/net/connector/device/running_config.rb +2 -0
- data/lib/net/connector/engine/command.rb +14 -2
- data/lib/net/connector/engine/configuration.rb +10 -7
- data/lib/net/connector/engine/dialogue.rb +52 -28
- data/lib/net/connector/engine/errors.rb +40 -55
- data/lib/net/connector/engine/execution.rb +32 -5
- data/lib/net/connector/engine/log.rb +11 -9
- data/lib/net/connector/engine/session.rb +38 -7
- data/lib/net/connector/engine/terminal_renderer.rb +7 -4
- data/lib/net/connector/netdisco/batch.rb +31 -3
- data/lib/net/connector/netdisco/cli.rb +73 -31
- data/lib/net/connector/netdisco/client.rb +206 -73
- data/lib/net/connector/netdisco/config_file.rb +26 -4
- data/lib/net/connector/netdisco/diagnostic.rb +91 -0
- data/lib/net/connector/netdisco/fleet.rb +103 -60
- data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
- data/lib/net/connector/netdisco/report.rb +94 -0
- data/lib/net/connector/netdisco/rules.rb +28 -5
- data/lib/net/connector/netdisco/settings.rb +194 -85
- data/lib/net/connector/netdisco/worker.rb +26 -17
- data/lib/net/connector/operations/backup_lock.rb +116 -0
- data/lib/net/connector/operations/local_backup.rb +49 -9
- data/lib/net/connector/operations/parse_output.rb +20 -3
- data/lib/net/connector/operations/private_file.rb +94 -5
- data/lib/net/connector/operations/safe_file.rb +62 -0
- data/lib/net/connector/operations/saved_config/legacy_index.rb +109 -0
- data/lib/net/connector/operations/saved_config.rb +40 -10
- data/lib/net/connector/operations/tftp/file_upload.rb +14 -1
- data/lib/net/connector/operations/tftp_backup.rb +78 -15
- data/lib/net/connector/operations/tftp_receipt.rb +73 -0
- data/lib/net/connector/operations/topology/immediate_strategy.rb +45 -0
- data/lib/net/connector/operations/topology/strategy.rb +20 -0
- data/lib/net/connector/operations/topology.rb +97 -29
- data/lib/net/connector/operations.rb +6 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +9 -2
- data/lib/net/connector/vendor/cisco_ios/topology.rb +10 -4
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +8 -1
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +5 -0
- data/lib/net/connector/vendor/h3c/topology.rb +9 -4
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +12 -3
- data/lib/net/connector/vendor/hillstone/topology.rb +9 -4
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +6 -0
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +14 -5
- data/lib/net/connector/vendor/palo_alto/topology.rb +7 -2
- data/lib/net/connector/vendor/radware/tftp_backup.rb +9 -2
- data/lib/net/connector/vendor/radware/topology.rb +1 -0
- data/lib/net/connector/version.rb +1 -1
- metadata +35 -5
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "expect/redactor"
|
|
4
|
+
|
|
3
5
|
module Net
|
|
4
6
|
module Connector
|
|
5
|
-
#
|
|
7
|
+
# 操作错误携带已脱敏上下文;敏感配置正文只存在于明确返回的业务结果中。
|
|
6
8
|
class Error < StandardError
|
|
7
9
|
attr_reader :code, :host, :phase, :command, :output, :source, :line, :underlying
|
|
8
10
|
|
|
@@ -48,6 +50,8 @@ module Net
|
|
|
48
50
|
|
|
49
51
|
class OutputLimitExceeded < Error; end
|
|
50
52
|
|
|
53
|
+
class ScriptOutputLimitExceeded < OutputLimitExceeded; end
|
|
54
|
+
|
|
51
55
|
class SessionBusy < Error; end
|
|
52
56
|
|
|
53
57
|
class UnsupportedOperation < Error; end
|
|
@@ -72,10 +76,16 @@ module Net
|
|
|
72
76
|
def inspect = "#<#{self.class} type=#{type}>"
|
|
73
77
|
end
|
|
74
78
|
|
|
75
|
-
#
|
|
79
|
+
# 管理连接器的秘密作用域和输出敏感性;字节过滤统一委托给 expect-pty。
|
|
76
80
|
class Redactor
|
|
81
|
+
MASK = "[REDACTED]".b.freeze
|
|
82
|
+
|
|
77
83
|
# 保存初始凭据,并建立第一版脱敏词表。
|
|
78
84
|
def initialize(*secrets)
|
|
85
|
+
unless Expect.constants(false).include?(:Redactor) && Expect::Redactor.respond_to?(:redact)
|
|
86
|
+
raise LoadError, "net-connector requires expect-pty with the public Expect::Redactor API"
|
|
87
|
+
end
|
|
88
|
+
|
|
79
89
|
@configured = secrets.compact.map(&:b).map(&:freeze).freeze
|
|
80
90
|
reset
|
|
81
91
|
end
|
|
@@ -83,9 +93,10 @@ module Net
|
|
|
83
93
|
# 重置动态词表,同时保留配置中的固定凭据。
|
|
84
94
|
def reset
|
|
85
95
|
@secrets = []
|
|
86
|
-
@
|
|
96
|
+
@patterns = nil
|
|
87
97
|
@scoped = false
|
|
88
98
|
@sensitive = false
|
|
99
|
+
@output_sensitive = false
|
|
89
100
|
@configured.each { |secret| remember(secret) }
|
|
90
101
|
end
|
|
91
102
|
|
|
@@ -94,18 +105,20 @@ module Net
|
|
|
94
105
|
return yield if reuse && @scoped
|
|
95
106
|
|
|
96
107
|
previous = @secrets
|
|
97
|
-
|
|
108
|
+
previous_patterns = @patterns
|
|
98
109
|
previous_scoped = @scoped
|
|
99
110
|
previous_sensitive = @sensitive
|
|
111
|
+
previous_output_sensitive = @output_sensitive
|
|
100
112
|
@secrets = previous.dup
|
|
101
113
|
@scoped = true
|
|
102
114
|
begin
|
|
103
115
|
yield
|
|
104
116
|
ensure
|
|
105
117
|
@secrets = previous
|
|
106
|
-
@
|
|
118
|
+
@patterns = previous_patterns
|
|
107
119
|
@scoped = previous_scoped
|
|
108
120
|
@sensitive = previous_sensitive
|
|
121
|
+
@output_sensitive = previous_output_sensitive
|
|
109
122
|
end
|
|
110
123
|
end
|
|
111
124
|
|
|
@@ -115,7 +128,16 @@ module Net
|
|
|
115
128
|
# 判断当前脱敏范围是否包含敏感交互。
|
|
116
129
|
def sensitive? = @sensitive
|
|
117
130
|
|
|
118
|
-
#
|
|
131
|
+
# 输出敏感时不把正文加入词表;只在当前范围内屏蔽日志和任意错误正文。
|
|
132
|
+
def output_sensitive!
|
|
133
|
+
@output_sensitive = true
|
|
134
|
+
sensitive!
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# 后续查询继承输出边界,即使查询命令本身没有敏感标记。
|
|
138
|
+
def output_sensitive? = @output_sensitive
|
|
139
|
+
|
|
140
|
+
# 只维护当前作用域的注册词;匹配次序和重叠区间交给共享过滤器。
|
|
119
141
|
def remember(secret)
|
|
120
142
|
return if secret.nil? || secret.empty?
|
|
121
143
|
|
|
@@ -123,63 +145,26 @@ module Net
|
|
|
123
145
|
return if @secrets.include?(bytes)
|
|
124
146
|
|
|
125
147
|
@secrets << bytes.freeze
|
|
126
|
-
@
|
|
127
|
-
@pattern = nil
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
# 保留可能跨下一分片的尾部;完整匹配始终作为整体脱敏。
|
|
131
|
-
def stream_chunk(bytes, final: false)
|
|
132
|
-
return [call(bytes), "".b] if final || @secrets.empty?
|
|
133
|
-
|
|
134
|
-
retained = [@secrets.first.bytesize, "[REDACTED]".bytesize].max - 1
|
|
135
|
-
boundary = [bytes.bytesize - retained, 0].max
|
|
136
|
-
matches(bytes).each do |start, finish|
|
|
137
|
-
if start < boundary && finish > boundary
|
|
138
|
-
boundary = start
|
|
139
|
-
break
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
[call(bytes.byteslice(0, boundary)), bytes.byteslice(boundary..)]
|
|
148
|
+
@patterns = nil
|
|
143
149
|
end
|
|
144
150
|
|
|
145
|
-
#
|
|
151
|
+
# 一次性诊断不共享流状态;原标记作为不透明区间,避免再次展开它。
|
|
146
152
|
def call(text)
|
|
147
|
-
text
|
|
148
|
-
return text if @secrets.empty?
|
|
149
|
-
|
|
150
|
-
output = +"".b
|
|
151
|
-
offset = 0
|
|
152
|
-
matches(text).each do |start, finish|
|
|
153
|
-
output << text.byteslice(offset, start - offset) << "[REDACTED]"
|
|
154
|
-
offset = finish
|
|
155
|
-
end
|
|
156
|
-
output << text.byteslice(offset..)
|
|
153
|
+
Expect::Redactor.redact(text.to_s.b, patterns, replacement: MASK)
|
|
157
154
|
end
|
|
158
155
|
|
|
159
|
-
#
|
|
160
|
-
def
|
|
161
|
-
|
|
162
|
-
private
|
|
163
|
-
|
|
164
|
-
# 前瞻保留重叠匹配;跨标记边界的真实秘密不能被已有标记遮蔽。
|
|
165
|
-
def pattern
|
|
166
|
-
@pattern ||= /(?=(#{Regexp.union((@secrets + ["[REDACTED]"]).uniq.sort_by { |secret| -secret.bytesize })}))/n
|
|
156
|
+
# 每个日志目标独占过滤流,跨分片尾部和重叠掩码由依赖库维护。
|
|
157
|
+
def stream
|
|
158
|
+
Expect::Redactor.new(patterns, replacement: MASK)
|
|
167
159
|
end
|
|
168
160
|
|
|
169
|
-
#
|
|
170
|
-
def
|
|
171
|
-
|
|
172
|
-
text.to_enum(:scan, pattern).each do
|
|
173
|
-
match = Regexp.last_match
|
|
174
|
-
start, finish = match.begin(1), match.end(1)
|
|
175
|
-
if ranges.last && start < ranges.last.last
|
|
176
|
-
ranges.last[1] = [ranges.last.last, finish].max
|
|
177
|
-
else
|
|
178
|
-
ranges << [start, finish]
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
ranges
|
|
161
|
+
# 临时范围恢复时,日志流可以同步恢复注册词表,不缓存配置正文。
|
|
162
|
+
def patterns
|
|
163
|
+
@patterns ||= (@secrets.empty? ? [] : (@secrets + [MASK]).uniq).freeze
|
|
182
164
|
end
|
|
165
|
+
|
|
166
|
+
# 返回脱敏器类型摘要。
|
|
167
|
+
def inspect = "#<#{self.class}>"
|
|
183
168
|
end
|
|
184
169
|
end
|
|
185
170
|
end
|
|
@@ -18,6 +18,8 @@ module Net
|
|
|
18
18
|
@prompt = prompt
|
|
19
19
|
@context = {}
|
|
20
20
|
@steps = []
|
|
21
|
+
@output_bytes = 0
|
|
22
|
+
@output_limit = session.configuration.max_script_output_bytes
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
# 依次准备、执行和记录脚本命令;失败时保留已完成步骤并统一抛错。
|
|
@@ -30,24 +32,38 @@ module Net
|
|
|
30
32
|
|
|
31
33
|
@current_command = command
|
|
32
34
|
started = Expect.monotonic
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
step = nil
|
|
36
|
+
response = query(command) do |received|
|
|
37
|
+
step = CommandResult.new(command: command, output: received.output, prompt: received.prompt,
|
|
38
|
+
duration: Expect.monotonic - started)
|
|
39
|
+
steps << step
|
|
40
|
+
end
|
|
37
41
|
# 设备已经完成命令;后处理失败也不能从部分结果中抹去其副作用。
|
|
38
42
|
@after_command.call(command, response, self)
|
|
39
43
|
yield step if block_given?
|
|
44
|
+
check_output_budget!(@last_query_command, completed: true)
|
|
40
45
|
rescue => error
|
|
41
46
|
raise @session.normalize_error(error, phase: :script, command: @current_command), cause: nil
|
|
42
47
|
end
|
|
43
48
|
end
|
|
49
|
+
check_output_budget!(@last_query_command, completed: true)
|
|
44
50
|
Result.new(steps: steps)
|
|
45
51
|
end
|
|
46
52
|
|
|
47
53
|
# 厂商后续查询复用同一信道和错误处理,不开启新的批处理。
|
|
48
54
|
def query(command)
|
|
49
55
|
command = Command.new(command) unless command.is_a?(Command)
|
|
50
|
-
|
|
56
|
+
check_output_budget!(command, completed: false)
|
|
57
|
+
prompt = @prompt&.call(command)
|
|
58
|
+
# 提示符回调也可能追加查询,实际发送前重新检查它消耗的预算。
|
|
59
|
+
check_output_budget!(command, completed: false) if @prompt
|
|
60
|
+
response = @session.exchange(command, timeout: @command_timeout, prompt: prompt)
|
|
61
|
+
@last_query_command = command
|
|
62
|
+
@output_bytes += response.raw.bytesize
|
|
63
|
+
# 主命令先记录完整步骤,再检查超额;追加查询同样计入预算,但不改变原 steps 结构。
|
|
64
|
+
yield response if block_given?
|
|
65
|
+
check_output_budget!(command, completed: true)
|
|
66
|
+
response
|
|
51
67
|
end
|
|
52
68
|
|
|
53
69
|
# 只有当前会话未特权时才执行特权认证。
|
|
@@ -59,6 +75,17 @@ module Net
|
|
|
59
75
|
def failure(message)
|
|
60
76
|
@session.error(ScriptError, message, phase: :script, command: @current_command)
|
|
61
77
|
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# 单响应上限仍约束正在读取的命令;累计预算阻止继续发送,不承诺设备尚未执行。
|
|
82
|
+
def check_output_budget!(command, completed:)
|
|
83
|
+
return unless @output_limit && (completed ? @output_bytes > @output_limit : @output_bytes >= @output_limit)
|
|
84
|
+
|
|
85
|
+
raise @session.error(ScriptOutputLimitExceeded,
|
|
86
|
+
"script output reached max_script_output_bytes; commands already sent may have executed",
|
|
87
|
+
phase: :script, command: command), cause: nil
|
|
88
|
+
end
|
|
62
89
|
end
|
|
63
90
|
end
|
|
64
91
|
end
|
|
@@ -89,7 +89,7 @@ module Net
|
|
|
89
89
|
|
|
90
90
|
# 应用注入的日志器没有终端写入器,改由日志器记录完整回显。
|
|
91
91
|
def response_output(bytes)
|
|
92
|
-
return unless detailed? && !@writer
|
|
92
|
+
return unless detailed? && !@writer && !@redactor.output_sensitive?
|
|
93
93
|
|
|
94
94
|
@redactor.call(TerminalRenderer.render(@redactor.call(bytes))).each_line do |line|
|
|
95
95
|
@logger.debug("#{@tag} #{line.chomp}") if ::Logger::DEBUG >= @event_level
|
|
@@ -169,18 +169,17 @@ module Net
|
|
|
169
169
|
|
|
170
170
|
# 文件写入边界保留短尾部,避免分片或终端控制符拼出明文凭据。
|
|
171
171
|
class RedactingWriter
|
|
172
|
-
#
|
|
172
|
+
# 保存目标与作用域;每个日志目标独占 expect-pty 的过滤流。
|
|
173
173
|
def initialize(target, redactor)
|
|
174
174
|
@target = target
|
|
175
175
|
@redactor = redactor
|
|
176
|
-
@
|
|
176
|
+
@filter = redactor.stream
|
|
177
177
|
end
|
|
178
178
|
|
|
179
179
|
# 写入已确认安全的前缀,暂存可能与下一分片组成秘密的尾部。
|
|
180
180
|
def write(bytes)
|
|
181
|
-
@
|
|
182
|
-
|
|
183
|
-
@target.write(safe)
|
|
181
|
+
@filter.patterns = @redactor.patterns
|
|
182
|
+
@target.write(@filter.append(bytes.b))
|
|
184
183
|
bytes.bytesize
|
|
185
184
|
end
|
|
186
185
|
|
|
@@ -189,11 +188,13 @@ module Net
|
|
|
189
188
|
|
|
190
189
|
# 在日志结束时脱敏并写出剩余尾部。
|
|
191
190
|
def finish
|
|
192
|
-
|
|
193
|
-
|
|
191
|
+
@filter.patterns = @redactor.patterns
|
|
192
|
+
# 保留旧日志的完整词匹配契约;配置正文由 Session 的敏感范围直接隔离。
|
|
193
|
+
@target.write(@filter.finish(partial: false))
|
|
194
194
|
flush
|
|
195
195
|
end
|
|
196
196
|
end
|
|
197
|
+
|
|
197
198
|
private_constant :RedactingWriter
|
|
198
199
|
|
|
199
200
|
# 以私有权限打开设备日志文件。
|
|
@@ -224,7 +225,8 @@ module Net
|
|
|
224
225
|
|
|
225
226
|
# 将日志异常包装成统一错误并保留脱敏原因。
|
|
226
227
|
def failure(message, error)
|
|
227
|
-
LogError.new(message, phase: :logging,
|
|
228
|
+
LogError.new(message, phase: :logging,
|
|
229
|
+
underlying: UnderlyingError.new(error, @redactor, sensitive: @redactor.sensitive?))
|
|
228
230
|
end
|
|
229
231
|
end
|
|
230
232
|
end
|
|
@@ -10,6 +10,8 @@ module Net
|
|
|
10
10
|
module Connector
|
|
11
11
|
# 连接状态、传输层和日志的唯一所有者;同一时刻只允许一个操作占用会话。
|
|
12
12
|
class Session
|
|
13
|
+
MAX_ERROR_OUTPUT_BYTES = 4096
|
|
14
|
+
|
|
13
15
|
attr_reader :configuration, :transport, :reader, :redactor, :state, :prompt
|
|
14
16
|
|
|
15
17
|
# 创建认证、响应读取、脱敏和日志对象,并把会话置于关闭状态。
|
|
@@ -75,6 +77,13 @@ module Net
|
|
|
75
77
|
end
|
|
76
78
|
end
|
|
77
79
|
|
|
80
|
+
# 文件业务必须先取得路径锁,再调用会话;不能持有会话租约去等待另一个备份者。
|
|
81
|
+
def assert_path_lock_order!(phase)
|
|
82
|
+
return unless @lock.locked?
|
|
83
|
+
|
|
84
|
+
raise error(SessionBusy, "backup path ownership must be acquired before a session operation", phase: phase), cause: nil
|
|
85
|
+
end
|
|
86
|
+
|
|
78
87
|
# 一次操作从连接到结果处理始终持锁;异常或 throw 中断都关闭未完成会话。
|
|
79
88
|
def perform_locked(phase)
|
|
80
89
|
@performing = true
|
|
@@ -140,6 +149,19 @@ module Net
|
|
|
140
149
|
end
|
|
141
150
|
end
|
|
142
151
|
|
|
152
|
+
# 批次准备和最终清理也可能读取配置;在作用域退出前归一化其异常,
|
|
153
|
+
# 防止清理钩子的消息或回溯将已完成步骤中的正文带回诊断通道。
|
|
154
|
+
def output_scope(enabled)
|
|
155
|
+
return yield unless enabled
|
|
156
|
+
|
|
157
|
+
redactor.scope do
|
|
158
|
+
redactor.output_sensitive!
|
|
159
|
+
yield
|
|
160
|
+
rescue => error
|
|
161
|
+
raise normalize_error(error, phase: :script), cause: nil
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
143
165
|
# 复用完整命令的脱敏范围;厂商后续查询产生的秘密保留到外层回调结束。
|
|
144
166
|
def exchange(command, timeout:, prompt: nil)
|
|
145
167
|
redactor.scope(reuse: true) do
|
|
@@ -147,9 +169,10 @@ module Net
|
|
|
147
169
|
started = Expect.monotonic
|
|
148
170
|
@log.event("command_start", text: command.sensitive? ? "[REDACTED]" : command.text)
|
|
149
171
|
sensitive_dialogue = [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
|
|
150
|
-
|
|
172
|
+
private_output = command.sensitive? || redactor.output_sensitive? || sensitive_dialogue
|
|
173
|
+
@log.event("device_output", level: :debug) if @log.detailed? && !private_output
|
|
151
174
|
response = exchange_command(command, timeout: timeout, prompt: prompt)
|
|
152
|
-
@log.response_output(response.raw) unless
|
|
175
|
+
@log.response_output(response.raw) unless private_output
|
|
153
176
|
@log.event("command_complete", status: "response_received")
|
|
154
177
|
@log.event("command_detail", level: :debug,
|
|
155
178
|
duration_ms: ((Expect.monotonic - started) * 1000).round,
|
|
@@ -165,6 +188,7 @@ module Net
|
|
|
165
188
|
|
|
166
189
|
# 在用户钩子运行前标记敏感上下文,动态交互尚未返回时也能保护其异常。
|
|
167
190
|
def protect_command(command)
|
|
191
|
+
redactor.output_sensitive! if command.output_sensitive?
|
|
168
192
|
if command.sensitive? || [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
|
|
169
193
|
redactor.sensitive!
|
|
170
194
|
end
|
|
@@ -189,7 +213,7 @@ module Net
|
|
|
189
213
|
|
|
190
214
|
response
|
|
191
215
|
end
|
|
192
|
-
if command.sensitive? || interactions.any?(&:sensitive?)
|
|
216
|
+
if command.sensitive? || redactor.output_sensitive? || interactions.any?(&:sensitive?)
|
|
193
217
|
@log.pause(&operation)
|
|
194
218
|
else
|
|
195
219
|
operation.call
|
|
@@ -220,20 +244,20 @@ module Net
|
|
|
220
244
|
|
|
221
245
|
# 创建带脱敏上下文、阶段、命令和输出尾部的领域错误。
|
|
222
246
|
def error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
|
|
223
|
-
sensitive = redactor.sensitive? || command&.sensitive?
|
|
247
|
+
sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
|
|
224
248
|
message = safe_error_text(message, sensitive: sensitive)
|
|
225
249
|
output = safe_error_text(output, sensitive: sensitive)
|
|
226
250
|
klass.new(message, host: configuration.host, phase: phase,
|
|
227
251
|
command: command && (command.sensitive? ? "[REDACTED]" : redactor.call(command.text)),
|
|
228
252
|
source: command&.source, line: command&.line,
|
|
229
|
-
output: output
|
|
253
|
+
output: truncate_output(output),
|
|
230
254
|
underlying: underlying && UnderlyingError.new(underlying, redactor, sensitive: sensitive), **context)
|
|
231
255
|
end
|
|
232
256
|
|
|
233
257
|
# 将底层异常映射为连接、传输、超时或内部错误,并保留安全上下文。
|
|
234
258
|
def normalize_error(exception, phase:, command: nil)
|
|
235
259
|
if exception.is_a?(Error)
|
|
236
|
-
sensitive = redactor.sensitive? || command&.sensitive?
|
|
260
|
+
sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
|
|
237
261
|
failed_command = exception.command || command&.text
|
|
238
262
|
if command&.sensitive? || (sensitive && exception.command && exception.command != command&.text)
|
|
239
263
|
failed_command = "[REDACTED]"
|
|
@@ -245,7 +269,7 @@ module Net
|
|
|
245
269
|
host: exception.host || configuration.host, phase: exception.phase || phase,
|
|
246
270
|
command: failed_command && redactor.call(failed_command),
|
|
247
271
|
source: exception.source || command&.source, line: exception.line || command&.line,
|
|
248
|
-
output: output
|
|
272
|
+
output: truncate_output(output),
|
|
249
273
|
underlying: exception.underlying && UnderlyingError.new(exception.underlying, redactor, sensitive: sensitive)
|
|
250
274
|
)
|
|
251
275
|
end
|
|
@@ -265,6 +289,13 @@ module Net
|
|
|
265
289
|
|
|
266
290
|
private :safe_error_text
|
|
267
291
|
|
|
292
|
+
# 必须先脱敏再截取尾部,避免截断凭据后逃过完整词匹配。
|
|
293
|
+
def truncate_output(text)
|
|
294
|
+
text.byteslice(-MAX_ERROR_OUTPUT_BYTES, MAX_ERROR_OUTPUT_BYTES) || text
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
private :truncate_output
|
|
298
|
+
|
|
268
299
|
# 返回不包含凭据的会话状态摘要。
|
|
269
300
|
def inspect = "#<#{self.class} state=#{state} host=#{configuration.host.inspect}>"
|
|
270
301
|
|
|
@@ -8,20 +8,21 @@ module Net
|
|
|
8
8
|
# 把终端编辑控制符渲染成可读、兼容 UTF-8 和二进制的逐行日志。
|
|
9
9
|
class TerminalRenderer
|
|
10
10
|
# 将字节流完整渲染为字符串,供测试和一次性转换使用。
|
|
11
|
-
def self.render(input)
|
|
11
|
+
def self.render(input, strict_utf8: false)
|
|
12
12
|
output = StringIO.new("".b)
|
|
13
|
-
renderer = new(output)
|
|
13
|
+
renderer = new(output, strict_utf8: strict_utf8)
|
|
14
14
|
renderer.write(input)
|
|
15
15
|
renderer.finish
|
|
16
16
|
output.string
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
# 初始化当前行、光标和转义序列状态。
|
|
20
|
-
def initialize(target, max_line_bytes: 32 * 1024 * 1024)
|
|
20
|
+
def initialize(target, max_line_bytes: 32 * 1024 * 1024, strict_utf8: false)
|
|
21
21
|
raise ArgumentError, "target must respond to write" unless target.respond_to?(:write)
|
|
22
22
|
unless max_line_bytes.is_a?(Integer) && max_line_bytes.positive?
|
|
23
23
|
raise ArgumentError, "max_line_bytes must be positive"
|
|
24
24
|
end
|
|
25
|
+
raise ArgumentError, "strict_utf8 must be true or false" unless [true, false].include?(strict_utf8)
|
|
25
26
|
|
|
26
27
|
@target = target
|
|
27
28
|
@line = "".b
|
|
@@ -29,6 +30,7 @@ module Net
|
|
|
29
30
|
@escape_state = nil
|
|
30
31
|
@csi_parameters = "".b
|
|
31
32
|
@max_line_bytes = max_line_bytes
|
|
33
|
+
@strict_utf8 = strict_utf8
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
# 逐字节消费终端输出,并返回输入字节数。
|
|
@@ -146,10 +148,11 @@ module Net
|
|
|
146
148
|
@cursor = 0
|
|
147
149
|
end
|
|
148
150
|
|
|
149
|
-
#
|
|
151
|
+
# 日志把非法字节转义;解析必须拒绝终端编辑产生的损坏字节,不能制造可写业务证据。
|
|
150
152
|
def utf8_safe(line)
|
|
151
153
|
text = line.dup.force_encoding(Encoding::UTF_8)
|
|
152
154
|
return text.b if text.valid_encoding?
|
|
155
|
+
raise Encoding::InvalidByteSequenceError, "terminal editing produced invalid UTF-8" if @strict_utf8
|
|
153
156
|
|
|
154
157
|
text.scrub { |invalid| invalid.bytes.map { |byte| format("\\x%02X", byte) }.join }.b
|
|
155
158
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
|
+
require_relative "diagnostic"
|
|
4
5
|
|
|
5
6
|
module Net
|
|
6
7
|
module Connector
|
|
@@ -8,9 +9,29 @@ module Net
|
|
|
8
9
|
# 单台设备的最终结果,保留状态、配置产物和错误类型。
|
|
9
10
|
Outcome = Data.define(:device, :status, :backup, :error_code, :error_type,
|
|
10
11
|
:started_at, :finished_at) do
|
|
12
|
+
attr_reader :diagnostic
|
|
13
|
+
|
|
11
14
|
# 创建结果,允许计划阶段尚无执行时间。
|
|
12
|
-
def initialize(device:, status:, backup:, error_code:, error_type:, started_at: nil, finished_at: nil
|
|
13
|
-
|
|
15
|
+
def initialize(device:, status:, backup:, error_code:, error_type:, started_at: nil, finished_at: nil,
|
|
16
|
+
duration_ms: nil, diagnostic: nil)
|
|
17
|
+
unless duration_ms.nil? || (duration_ms.is_a?(Numeric) && duration_ms.real? && duration_ms.finite? && duration_ms >= 0)
|
|
18
|
+
raise ArgumentError, "duration_ms must be nonnegative and finite"
|
|
19
|
+
end
|
|
20
|
+
raise ArgumentError, "diagnostic must be a Diagnostic" unless diagnostic.nil? || diagnostic.instance_of?(Diagnostic)
|
|
21
|
+
|
|
22
|
+
@duration_ms, @diagnostic = duration_ms, diagnostic
|
|
23
|
+
super(device: device, status: status, backup: backup, error_code: error_code, error_type: error_type,
|
|
24
|
+
started_at: started_at, finished_at: finished_at)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Data 成员保持不变;Worker 加上时间、旧文件迁移调整产物时仍保留内部元数据。
|
|
28
|
+
def with(**attributes)
|
|
29
|
+
return self if attributes.empty?
|
|
30
|
+
|
|
31
|
+
metadata = { duration_ms: @duration_ms, diagnostic: diagnostic }
|
|
32
|
+
metadata[:duration_ms] = nil if attributes.key?(:started_at) || attributes.key?(:finished_at)
|
|
33
|
+
metadata[:diagnostic] = nil if attributes.key?(:error_code) || attributes.key?(:error_type)
|
|
34
|
+
self.class.new(**to_h, **metadata.merge(attributes))
|
|
14
35
|
end
|
|
15
36
|
|
|
16
37
|
# 判断设备是否完成配置保存或上报上传成功。
|
|
@@ -24,7 +45,9 @@ module Net
|
|
|
24
45
|
|
|
25
46
|
# 计算设备任务的耗时,计划阶段没有时间时返回空值。
|
|
26
47
|
def duration_ms
|
|
27
|
-
|
|
48
|
+
return @duration_ms unless @duration_ms.nil?
|
|
49
|
+
|
|
50
|
+
[((finished_at - started_at) * 1000).round, 0].max if started_at && finished_at
|
|
28
51
|
end
|
|
29
52
|
end
|
|
30
53
|
|
|
@@ -40,6 +63,9 @@ module Net
|
|
|
40
63
|
# 空清单与已尝试但未全部成功的批次分别标记。
|
|
41
64
|
def status = outcomes.empty? ? :no_devices : (success? ? :succeeded : :incomplete)
|
|
42
65
|
|
|
66
|
+
# 单独请求 v2 包装,不改变旧 Data 的成员、解构或默认 summary。
|
|
67
|
+
def report(policy: :strict) = Report.new(self, policy: policy)
|
|
68
|
+
|
|
43
69
|
# 生成可写入报告及供命令行展示的结构化摘要。
|
|
44
70
|
def summary
|
|
45
71
|
{
|
|
@@ -66,6 +92,8 @@ module Net
|
|
|
66
92
|
}
|
|
67
93
|
end
|
|
68
94
|
end
|
|
95
|
+
|
|
96
|
+
autoload :Report, File.expand_path("report", __dir__)
|
|
69
97
|
end
|
|
70
98
|
end
|
|
71
99
|
end
|