net-connector 0.4.1 → 0.5.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 +50 -0
- data/CONTRIBUTING.md +40 -0
- data/README.md +93 -18
- data/SECURITY.md +11 -0
- data/docs/VERIFICATION.md +139 -6
- data/docs/architecture.md +248 -39
- data/lib/net/connector/device/base.rb +28 -101
- data/lib/net/connector/device/local_backup.rb +96 -0
- data/lib/net/connector/device/profile.rb +8 -3
- data/lib/net/connector/device/running_config/strategy.rb +1 -1
- data/lib/net/connector/device/running_config.rb +46 -5
- data/lib/net/connector/device/save_config.rb +21 -0
- data/lib/net/connector/device/tftp/file_upload.rb +53 -0
- data/lib/net/connector/device/tftp/receipt.rb +71 -0
- data/lib/net/connector/device/tftp/strategy.rb +67 -0
- data/lib/net/connector/device/tftp/target.rb +63 -0
- data/lib/net/connector/device/tftp.rb +117 -0
- data/lib/net/connector/device/topology/immediate_strategy.rb +40 -0
- data/lib/net/connector/device/topology/interface_description.rb +46 -0
- data/lib/net/connector/device/topology/interface_name.rb +58 -0
- data/lib/net/connector/device/topology/strategy.rb +75 -0
- data/lib/net/connector/device/topology.rb +277 -0
- data/lib/net/connector/engine/authentication.rb +1 -1
- data/lib/net/connector/engine/command.rb +14 -2
- data/lib/net/connector/engine/configuration.rb +11 -8
- data/lib/net/connector/engine/dialogue.rb +59 -35
- data/lib/net/connector/engine/error_metadata.rb +50 -0
- data/lib/net/connector/engine/errors.rb +55 -56
- data/lib/net/connector/engine/execution.rb +44 -10
- data/lib/net/connector/engine/log/event.rb +66 -0
- data/lib/net/connector/engine/log/formatter.rb +16 -0
- data/lib/net/connector/engine/log/messages.rb +52 -0
- data/lib/net/connector/engine/log/stream.rb +56 -0
- data/lib/net/connector/engine/log.rb +107 -84
- data/lib/net/connector/engine/session.rb +115 -43
- data/lib/net/connector/engine/terminal_renderer.rb +7 -4
- data/lib/net/connector/engine/terminal_text.rb +33 -0
- data/lib/net/connector/engine/transport.rb +2 -2
- data/lib/net/connector/netdisco/batch.rb +25 -33
- data/lib/net/connector/netdisco/cli.rb +71 -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/device.rb +4 -5
- data/lib/net/connector/netdisco/diagnostic.rb +61 -0
- data/lib/net/connector/netdisco/fleet.rb +96 -66
- data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
- data/lib/net/connector/netdisco/report.rb +115 -0
- data/lib/net/connector/netdisco/result_store.rb +2 -2
- data/lib/net/connector/netdisco/rules.rb +28 -5
- data/lib/net/connector/netdisco/settings.rb +188 -85
- data/lib/net/connector/netdisco/worker.rb +26 -17
- data/lib/net/connector/storage/backup_lock.rb +116 -0
- data/lib/net/connector/storage/private_file.rb +109 -0
- data/lib/net/connector/storage/safe_file.rb +62 -0
- data/lib/net/connector/{operations → storage}/saved_config.rb +25 -20
- data/lib/net/connector/storage.rb +15 -0
- data/lib/net/connector/textfsm.rb +72 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +12 -5
- data/lib/net/connector/vendor/cisco_ios/topology.rb +12 -6
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +11 -4
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +9 -4
- data/lib/net/connector/vendor/h3c/topology.rb +11 -6
- data/lib/net/connector/vendor/h3c.rb +3 -3
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +15 -6
- data/lib/net/connector/vendor/hillstone/topology.rb +11 -6
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +9 -3
- data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -1
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +17 -8
- data/lib/net/connector/vendor/palo_alto/topology.rb +9 -7
- data/lib/net/connector/vendor/radware/tftp_backup.rb +12 -5
- data/lib/net/connector/vendor/radware/topology.rb +3 -2
- data/lib/net/connector/version.rb +1 -1
- data/lib/net/connector.rb +1 -6
- metadata +54 -38
- data/lib/net/connector/device/interface_description.rb +0 -44
- data/lib/net/connector/device/interface_name.rb +0 -56
- data/lib/net/connector/engine/base.rb +0 -4
- data/lib/net/connector/engine/log_messages.rb +0 -47
- data/lib/net/connector/engine/profile.rb +0 -4
- data/lib/net/connector/engine.rb +0 -4
- data/lib/net/connector/operations/local_backup.rb +0 -49
- data/lib/net/connector/operations/parse_output.rb +0 -60
- data/lib/net/connector/operations/private_file.rb +0 -25
- data/lib/net/connector/operations/running_config/cisco.rb +0 -14
- data/lib/net/connector/operations/running_config/hillstone.rb +0 -12
- data/lib/net/connector/operations/running_config/palo_alto.rb +0 -12
- data/lib/net/connector/operations/running_config/strategy.rb +0 -3
- data/lib/net/connector/operations/running_config.rb +0 -16
- data/lib/net/connector/operations/tftp/cisco_ios.rb +0 -11
- data/lib/net/connector/operations/tftp/cisco_nxos.rb +0 -11
- data/lib/net/connector/operations/tftp/file_upload.rb +0 -42
- data/lib/net/connector/operations/tftp/h3c.rb +0 -11
- data/lib/net/connector/operations/tftp/hillstone.rb +0 -11
- data/lib/net/connector/operations/tftp/huawei.rb +0 -11
- data/lib/net/connector/operations/tftp/palo_alto.rb +0 -11
- data/lib/net/connector/operations/tftp/radware.rb +0 -11
- data/lib/net/connector/operations/tftp/strategy.rb +0 -75
- data/lib/net/connector/operations/tftp_backup.rb +0 -120
- data/lib/net/connector/operations/topology/cisco.rb +0 -11
- data/lib/net/connector/operations/topology/h3c.rb +0 -11
- data/lib/net/connector/operations/topology/hillstone.rb +0 -11
- data/lib/net/connector/operations/topology/palo_alto.rb +0 -11
- data/lib/net/connector/operations/topology/radware.rb +0 -11
- data/lib/net/connector/operations/topology/strategy.rb +0 -67
- data/lib/net/connector/operations/topology.rb +0 -193
- data/lib/net/connector/operations.rb +0 -20
|
@@ -130,62 +130,86 @@ module Net
|
|
|
130
130
|
counts = Hash.new(0)
|
|
131
131
|
bytes = 0
|
|
132
132
|
loop do
|
|
133
|
-
event =
|
|
133
|
+
event = read_event(patterns, deadline)
|
|
134
134
|
raw << event.before unless event.before.empty?
|
|
135
135
|
output << event.before unless event.before.empty?
|
|
136
136
|
bytes += event.before.bytesize + event.match.bytesize
|
|
137
|
-
|
|
138
|
-
raise @session.error(OutputLimitExceeded, "device output exceeded max_output_bytes",
|
|
139
|
-
phase: phase, command: command, output: (raw + [event.match]).join), cause: nil
|
|
140
|
-
end
|
|
141
|
-
raise read_error(event, phase, command, raw.join), cause: nil unless event.matched?
|
|
137
|
+
validate_event!(event, bytes, raw, phase, command)
|
|
142
138
|
|
|
143
139
|
if event.index == patterns.size
|
|
144
140
|
raw << event.match
|
|
145
141
|
output << event.match
|
|
146
142
|
elsif event.index == patterns.size - 1
|
|
147
|
-
|
|
148
|
-
raise @session.error(PromptError, "prompt pattern did not consume any output",
|
|
149
|
-
phase: phase, command: command, output: raw.join), cause: nil
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
raw << event.match
|
|
153
|
-
output << event.match
|
|
154
|
-
raw_text = raw.join
|
|
155
|
-
return Response.new(raw: raw_text, output: (output == raw) ? raw_text : output.join, prompt: event.match)
|
|
143
|
+
return finish_response(event.match, raw, output, phase, command)
|
|
156
144
|
elsif event.index < failures.size
|
|
157
|
-
|
|
158
|
-
raise @session.error(klass, "device connection failed (#{code})", phase: phase,
|
|
159
|
-
code: code, command: command), cause: nil
|
|
145
|
+
raise build_connection_error(failures.values.fetch(event.index), phase, command), cause: nil
|
|
160
146
|
else
|
|
161
147
|
reply = interactions.fetch(event.index - failures.size)
|
|
162
148
|
raw << event.match
|
|
163
149
|
output << event.match if reply.capture?
|
|
164
|
-
|
|
165
|
-
if event.match.empty? || (reply.limit && counts[reply] > reply.limit)
|
|
166
|
-
raise interaction_error("response rejected or repeated", phase, command), cause: nil
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
value = interaction_response(reply, event.match, phase, command)
|
|
170
|
-
raise interaction_error("response unavailable", phase, command), cause: nil unless value.is_a?(String)
|
|
171
|
-
|
|
172
|
-
@session.redactor.remember(value.chomp) if reply.sensitive?
|
|
173
|
-
@session.write(value, deadline: deadline, phase: phase, command: command)
|
|
150
|
+
respond(reply, event.match, counts, deadline, phase, command)
|
|
174
151
|
end
|
|
175
152
|
next if Expect.monotonic < deadline
|
|
176
153
|
|
|
177
|
-
raise
|
|
154
|
+
raise build_read_error(nil, phase, command, raw.join), cause: nil
|
|
178
155
|
end
|
|
179
156
|
end
|
|
180
157
|
|
|
181
158
|
private
|
|
182
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
|
+
|
|
183
207
|
# 动态口令尚未返回时无法加入词表,须在敏感范围内先归一化回调异常。
|
|
184
208
|
# 成功后由调用方登记响应,临时敏感标记不会影响后续普通命令的诊断。
|
|
185
209
|
def interaction_response(reply, prompt, phase, command)
|
|
186
210
|
return reply.response(prompt) unless reply.sensitive?
|
|
187
211
|
|
|
188
|
-
@session.redactor.
|
|
212
|
+
@session.redactor.with_scope(reuse: true) do
|
|
189
213
|
@session.redactor.sensitive!
|
|
190
214
|
reply.response(prompt)
|
|
191
215
|
rescue => error
|
|
@@ -194,17 +218,17 @@ module Net
|
|
|
194
218
|
end
|
|
195
219
|
|
|
196
220
|
# 将交互拒绝或响应缺失转换为认证或脚本错误。
|
|
197
|
-
def
|
|
221
|
+
def build_interaction_error(message, phase, command)
|
|
198
222
|
authenticating = %i[login enable].include?(phase)
|
|
199
223
|
klass = authenticating ? AuthenticationError : ScriptError
|
|
200
224
|
label = authenticating ? "authentication" : "command interaction"
|
|
201
|
-
@session.
|
|
225
|
+
@session.build_error(klass, "#{label} #{message}", phase: phase, command: command)
|
|
202
226
|
end
|
|
203
227
|
|
|
204
228
|
# 根据 EOF、提示符、阶段和底层异常选择具体读取错误类型。
|
|
205
|
-
def
|
|
229
|
+
def build_read_error(event, phase, command, output)
|
|
206
230
|
if event&.error.is_a?(Exception)
|
|
207
|
-
return @session.
|
|
231
|
+
return @session.build_error(TransportError, "transport read failed", phase: phase, command: command,
|
|
208
232
|
underlying: event.error, output: output)
|
|
209
233
|
end
|
|
210
234
|
klass = if event&.error == :eof
|
|
@@ -216,7 +240,7 @@ module Net
|
|
|
216
240
|
else
|
|
217
241
|
CommandTimeout
|
|
218
242
|
end
|
|
219
|
-
@session.
|
|
243
|
+
@session.build_error(klass, "device response #{event&.error || :timeout}", phase: phase,
|
|
220
244
|
command: command, output: output)
|
|
221
245
|
end
|
|
222
246
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Net
|
|
4
|
+
module Connector
|
|
5
|
+
# 日志与报告共用固定诊断词表;未知元数据可能来自配置正文,不能作为自由文本输出。
|
|
6
|
+
module ErrorMetadata
|
|
7
|
+
CONNECTOR_TYPES = %w[Error ConnectionError AuthenticationError LoginTimeout CommandTimeout WriteTimeout
|
|
8
|
+
PromptError ConnectionClosed TransportError DeviceError ScriptError InternalError
|
|
9
|
+
OutputLimitExceeded ScriptOutputLimitExceeded SessionBusy UnsupportedOperation LogError ParsingError BackupBusy
|
|
10
|
+
BackupPersistenceError TftpCompletionError].freeze
|
|
11
|
+
TYPES = (CONNECTOR_TYPES.map { |name| "Net::Connector::#{name}" } +
|
|
12
|
+
%w[StandardError RuntimeError ArgumentError TypeError IOError EOFError SystemCallError ThreadError
|
|
13
|
+
RangeError KeyError IndexError NoMethodError NameError FrozenError NotImplementedError
|
|
14
|
+
Timeout::Error IO::TimeoutError Errno::EIO Errno::EACCES Errno::EPERM Errno::ENOSPC Errno::EROFS
|
|
15
|
+
Errno::ENOENT Errno::ENOTDIR Errno::EISDIR Errno::ELOOP Errno::EINVAL Errno::ENOSYS
|
|
16
|
+
Errno::ENOTSUP Errno::EOPNOTSUPP Errno::ETIMEDOUT Errno::ECONNREFUSED Errno::ECONNRESET
|
|
17
|
+
Net::Connector::Storage::PrivateFile::WriteError
|
|
18
|
+
Net::Connector::Storage::PrivateFile::PersistenceError
|
|
19
|
+
Net::Connector::Storage::PrivateFile::DirectorySyncUnsupported]).freeze
|
|
20
|
+
CODES = (CONNECTOR_TYPES.map { |name| name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym } +
|
|
21
|
+
%i[authentication_failed connection_failed connection_refused no_route connection_reset
|
|
22
|
+
connection_timeout host_key_changed host_key_untrusted rsa_too_small unsupported_cipher
|
|
23
|
+
ambiguous_description ambiguous_neighbor confirmation_required description_stages_unsupported
|
|
24
|
+
description_unconfirmed description_unsupported incomplete_configuration interface_missing
|
|
25
|
+
neighbor_discovery_unsupported parse_failed invalid_output_encoding persistence_unconfirmed stale_plan startup_config_missing
|
|
26
|
+
template_invalid template_missing template_unreadable transfer_failed transfer_finalize_failed
|
|
27
|
+
transfer_path_mismatch transfer_path_unconfirmed transfer_unconfirmed uncommitted_configuration
|
|
28
|
+
unrecognized_output unsupported_configuration_format verification_plan_changed candidate_isolation_unavailable
|
|
29
|
+
backup_durability_unsupported backup_finalize_failed backup_persistence_unconfirmed
|
|
30
|
+
file_finalize_failed file_persistence_unconfirmed file_write_failed directory_sync_unsupported]).freeze
|
|
31
|
+
PHASES = %i[connect login enable command script read write close logging backup collect discover parse
|
|
32
|
+
plan apply verify persist save interact tftp_backup report callback].freeze
|
|
33
|
+
private_constant :CONNECTOR_TYPES, :TYPES, :CODES, :PHASES
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
def code(value) = CODES.include?(value) ? value : nil
|
|
38
|
+
|
|
39
|
+
def phase(value) = PHASES.include?(value) ? value : nil
|
|
40
|
+
|
|
41
|
+
def known_type?(name) = name.instance_of?(String) && TYPES.include?(name)
|
|
42
|
+
|
|
43
|
+
def type(name)
|
|
44
|
+
return if name.nil?
|
|
45
|
+
|
|
46
|
+
known_type?(name) ? name.dup.freeze : "StandardError"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "expect/redactor"
|
|
4
|
+
require_relative "error_metadata"
|
|
5
|
+
|
|
3
6
|
module Net
|
|
4
7
|
module Connector
|
|
5
|
-
#
|
|
8
|
+
# 操作错误携带已脱敏上下文;敏感配置正文只存在于明确返回的业务结果中。
|
|
6
9
|
class Error < StandardError
|
|
7
10
|
attr_reader :code, :host, :phase, :command, :output, :source, :line, :underlying
|
|
8
11
|
|
|
@@ -20,6 +23,19 @@ module Net
|
|
|
20
23
|
@underlying = underlying
|
|
21
24
|
end
|
|
22
25
|
|
|
26
|
+
# 内置错误保留业务回执,但不复制可能含秘密的原生 cause 和调用栈。
|
|
27
|
+
# 自定义子类重新构造,避免其 message 等方法继续读取未脱敏的私有字段。
|
|
28
|
+
def with_diagnostics(message:, **context)
|
|
29
|
+
context = { code: code, host: host, phase: phase, command: command, output: output,
|
|
30
|
+
source: source, line: line, underlying: underlying }.merge(context)
|
|
31
|
+
return self.class.new(message, **context) unless ErrorMetadata.known_type?(self.class.name)
|
|
32
|
+
|
|
33
|
+
copy = self.class.allocate
|
|
34
|
+
instance_variables.each { |name| copy.instance_variable_set(name, instance_variable_get(name)) }
|
|
35
|
+
Error.instance_method(:initialize).bind_call(copy, message, **context)
|
|
36
|
+
copy
|
|
37
|
+
end
|
|
38
|
+
|
|
23
39
|
# 返回不展开命令和输出的错误摘要。
|
|
24
40
|
def inspect = "#<#{self.class} code=#{code.inspect} phase=#{phase.inspect} host=#{host.inspect}>"
|
|
25
41
|
end
|
|
@@ -48,6 +64,8 @@ module Net
|
|
|
48
64
|
|
|
49
65
|
class OutputLimitExceeded < Error; end
|
|
50
66
|
|
|
67
|
+
class ScriptOutputLimitExceeded < OutputLimitExceeded; end
|
|
68
|
+
|
|
51
69
|
class SessionBusy < Error; end
|
|
52
70
|
|
|
53
71
|
class UnsupportedOperation < Error; end
|
|
@@ -72,10 +90,16 @@ module Net
|
|
|
72
90
|
def inspect = "#<#{self.class} type=#{type}>"
|
|
73
91
|
end
|
|
74
92
|
|
|
75
|
-
#
|
|
93
|
+
# 管理连接器的秘密作用域和输出敏感性;字节过滤统一委托给 expect-pty。
|
|
76
94
|
class Redactor
|
|
95
|
+
MASK = "[REDACTED]".b.freeze
|
|
96
|
+
|
|
77
97
|
# 保存初始凭据,并建立第一版脱敏词表。
|
|
78
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
|
+
|
|
79
103
|
@configured = secrets.compact.map(&:b).map(&:freeze).freeze
|
|
80
104
|
reset
|
|
81
105
|
end
|
|
@@ -83,29 +107,32 @@ module Net
|
|
|
83
107
|
# 重置动态词表,同时保留配置中的固定凭据。
|
|
84
108
|
def reset
|
|
85
109
|
@secrets = []
|
|
86
|
-
@
|
|
110
|
+
@patterns = nil
|
|
87
111
|
@scoped = false
|
|
88
112
|
@sensitive = false
|
|
113
|
+
@output_sensitive = false
|
|
89
114
|
@configured.each { |secret| remember(secret) }
|
|
90
115
|
end
|
|
91
116
|
|
|
92
117
|
# 临时命令响应在错误归一化期间保留,离开作用域后不进入长期会话。
|
|
93
|
-
def
|
|
118
|
+
def with_scope(reuse: false)
|
|
94
119
|
return yield if reuse && @scoped
|
|
95
120
|
|
|
96
121
|
previous = @secrets
|
|
97
|
-
|
|
122
|
+
previous_patterns = @patterns
|
|
98
123
|
previous_scoped = @scoped
|
|
99
124
|
previous_sensitive = @sensitive
|
|
125
|
+
previous_output_sensitive = @output_sensitive
|
|
100
126
|
@secrets = previous.dup
|
|
101
127
|
@scoped = true
|
|
102
128
|
begin
|
|
103
129
|
yield
|
|
104
130
|
ensure
|
|
105
131
|
@secrets = previous
|
|
106
|
-
@
|
|
132
|
+
@patterns = previous_patterns
|
|
107
133
|
@scoped = previous_scoped
|
|
108
134
|
@sensitive = previous_sensitive
|
|
135
|
+
@output_sensitive = previous_output_sensitive
|
|
109
136
|
end
|
|
110
137
|
end
|
|
111
138
|
|
|
@@ -115,7 +142,16 @@ module Net
|
|
|
115
142
|
# 判断当前脱敏范围是否包含敏感交互。
|
|
116
143
|
def sensitive? = @sensitive
|
|
117
144
|
|
|
118
|
-
#
|
|
145
|
+
# 输出敏感时不把正文加入词表;只在当前范围内屏蔽日志和任意错误正文。
|
|
146
|
+
def output_sensitive!
|
|
147
|
+
@output_sensitive = true
|
|
148
|
+
sensitive!
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# 后续查询继承输出边界,即使查询命令本身没有敏感标记。
|
|
152
|
+
def output_sensitive? = @output_sensitive
|
|
153
|
+
|
|
154
|
+
# 只维护当前作用域的注册词;匹配次序和重叠区间交给共享过滤器。
|
|
119
155
|
def remember(secret)
|
|
120
156
|
return if secret.nil? || secret.empty?
|
|
121
157
|
|
|
@@ -123,63 +159,26 @@ module Net
|
|
|
123
159
|
return if @secrets.include?(bytes)
|
|
124
160
|
|
|
125
161
|
@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..)]
|
|
162
|
+
@patterns = nil
|
|
143
163
|
end
|
|
144
164
|
|
|
145
|
-
#
|
|
165
|
+
# 一次性诊断不共享流状态;原标记作为不透明区间,避免再次展开它。
|
|
146
166
|
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..)
|
|
167
|
+
Expect::Redactor.redact(text.to_s.b, patterns, replacement: MASK)
|
|
157
168
|
end
|
|
158
169
|
|
|
159
|
-
#
|
|
160
|
-
def
|
|
161
|
-
|
|
162
|
-
private
|
|
163
|
-
|
|
164
|
-
# 前瞻保留重叠匹配;跨标记边界的真实秘密不能被已有标记遮蔽。
|
|
165
|
-
def pattern
|
|
166
|
-
@pattern ||= /(?=(#{Regexp.union((@secrets + ["[REDACTED]"]).uniq.sort_by { |secret| -secret.bytesize })}))/n
|
|
170
|
+
# 每个日志目标独占过滤流,跨分片尾部和重叠掩码由依赖库维护。
|
|
171
|
+
def stream
|
|
172
|
+
Expect::Redactor.new(patterns, replacement: MASK)
|
|
167
173
|
end
|
|
168
174
|
|
|
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
|
|
175
|
+
# 临时范围恢复时,日志流可以同步恢复注册词表,不缓存配置正文。
|
|
176
|
+
def patterns
|
|
177
|
+
@patterns ||= (@secrets.empty? ? [] : (@secrets + [MASK]).uniq).freeze
|
|
182
178
|
end
|
|
179
|
+
|
|
180
|
+
# 返回脱敏器类型摘要。
|
|
181
|
+
def inspect = "#<#{self.class}>"
|
|
183
182
|
end
|
|
184
183
|
end
|
|
185
184
|
end
|
|
@@ -18,36 +18,59 @@ 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
|
|
23
|
+
@sensitive = false
|
|
21
24
|
end
|
|
22
25
|
|
|
26
|
+
# 汇总实际执行的命令及交互,包括批次准备和厂商追加查询。
|
|
27
|
+
def sensitive? = @sensitive
|
|
28
|
+
|
|
23
29
|
# 依次准备、执行和记录脚本命令;失败时保留已完成步骤并统一抛错。
|
|
24
|
-
def
|
|
30
|
+
def execute_script(script)
|
|
25
31
|
script.each do |original|
|
|
26
|
-
@session.
|
|
32
|
+
@session.with_command_redaction(original) do
|
|
27
33
|
@current_command = original
|
|
28
34
|
command = @prepare_command.call(original, self)
|
|
29
35
|
next unless command
|
|
30
36
|
|
|
31
37
|
@current_command = command
|
|
32
38
|
started = Expect.monotonic
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
step = nil
|
|
40
|
+
response = execute_command(command) do |received|
|
|
41
|
+
step = CommandResult.new(command: command, output: received.output, prompt: received.prompt,
|
|
42
|
+
duration: Expect.monotonic - started)
|
|
43
|
+
steps << step
|
|
44
|
+
end
|
|
37
45
|
# 设备已经完成命令;后处理失败也不能从部分结果中抹去其副作用。
|
|
38
46
|
@after_command.call(command, response, self)
|
|
39
47
|
yield step if block_given?
|
|
48
|
+
check_output_budget!(@last_query_command, completed: true)
|
|
40
49
|
rescue => error
|
|
41
50
|
raise @session.normalize_error(error, phase: :script, command: @current_command), cause: nil
|
|
42
51
|
end
|
|
43
52
|
end
|
|
53
|
+
check_output_budget!(@last_query_command, completed: true)
|
|
44
54
|
Result.new(steps: steps)
|
|
45
55
|
end
|
|
46
56
|
|
|
47
57
|
# 厂商后续查询复用同一信道和错误处理,不开启新的批处理。
|
|
48
|
-
def
|
|
58
|
+
def execute_command(command)
|
|
49
59
|
command = Command.new(command) unless command.is_a?(Command)
|
|
50
|
-
|
|
60
|
+
check_output_budget!(command, completed: false)
|
|
61
|
+
prompt = @prompt&.call(command)
|
|
62
|
+
# 提示符回调也可能追加查询,实际发送前重新检查它消耗的预算。
|
|
63
|
+
check_output_budget!(command, completed: false) if @prompt
|
|
64
|
+
response = @session.execute_command(command, timeout: @command_timeout, prompt: prompt) do
|
|
65
|
+
# 必须在 Session 恢复命令词表前读取;最终处理只能继承敏感性,不长期保留秘密。
|
|
66
|
+
@sensitive ||= @session.redactor.sensitive?
|
|
67
|
+
end
|
|
68
|
+
@last_query_command = command
|
|
69
|
+
@output_bytes += response.raw.bytesize
|
|
70
|
+
# 主命令先记录完整步骤,再检查超额;追加查询同样计入预算,但不改变原 steps 结构。
|
|
71
|
+
yield response if block_given?
|
|
72
|
+
check_output_budget!(command, completed: true)
|
|
73
|
+
response
|
|
51
74
|
end
|
|
52
75
|
|
|
53
76
|
# 只有当前会话未特权时才执行特权认证。
|
|
@@ -56,8 +79,19 @@ module Net
|
|
|
56
79
|
end
|
|
57
80
|
|
|
58
81
|
# 根据当前命令建立脚本阶段错误。
|
|
59
|
-
def
|
|
60
|
-
@session.
|
|
82
|
+
def build_error(message)
|
|
83
|
+
@session.build_error(ScriptError, message, phase: :script, command: @current_command)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# 单响应上限仍约束正在读取的命令;累计预算阻止继续发送,不承诺设备尚未执行。
|
|
89
|
+
def check_output_budget!(command, completed:)
|
|
90
|
+
return unless @output_limit && (completed ? @output_bytes > @output_limit : @output_bytes >= @output_limit)
|
|
91
|
+
|
|
92
|
+
raise @session.build_error(ScriptOutputLimitExceeded,
|
|
93
|
+
"script output reached max_script_output_bytes; commands already sent may have executed",
|
|
94
|
+
phase: :script, command: command), cause: nil
|
|
61
95
|
end
|
|
62
96
|
end
|
|
63
97
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../terminal_renderer"
|
|
4
|
+
require_relative "messages"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
class Log
|
|
9
|
+
# 在敏感作用域结束前冻结安全副本;应用 formatter 可直接读取字段。
|
|
10
|
+
class Event
|
|
11
|
+
attr_reader :name, :fields
|
|
12
|
+
|
|
13
|
+
def initialize(name, fields, redactor:)
|
|
14
|
+
@name = safe_text(name.is_a?(String) || name.is_a?(Symbol) ? name.to_s : "custom", redactor).freeze
|
|
15
|
+
@fields = fields.each_with_object({}) do |(key, value), result|
|
|
16
|
+
next unless (key.is_a?(Symbol) || key.is_a?(String)) && key.to_s.match?(/\A[a-z][a-z0-9_]{0,63}\z/)
|
|
17
|
+
next unless redactor.call(key.to_s) == key.to_s
|
|
18
|
+
next if key.to_s == "event"
|
|
19
|
+
|
|
20
|
+
result[key.to_sym] = safe_value(value, redactor)
|
|
21
|
+
end.freeze
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# 与文本呈现共用同一安全记录,调用方不能借 formatter 取得原始秘密。
|
|
26
|
+
def to_h = { event: name, **fields }.freeze
|
|
27
|
+
|
|
28
|
+
def to_s
|
|
29
|
+
details = to_h.reject { |key, _| key == :host }.map { |key, value| "#{key}=#{token(value)}" }.join(" ")
|
|
30
|
+
"[host=#{token(fields[:host])}] #{Messages.format(name, fields)} | #{details}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Ruby Logger 的默认 formatter 对非 String 消息调用 inspect。
|
|
34
|
+
alias inspect to_s
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def safe_value(value, redactor)
|
|
39
|
+
case value
|
|
40
|
+
when String, Symbol
|
|
41
|
+
safe_text(value.to_s, redactor).freeze
|
|
42
|
+
when Float
|
|
43
|
+
value.finite? && redactor.call(value.to_s) == value.to_s ? value : "[REDACTED]"
|
|
44
|
+
when Integer, TrueClass, FalseClass, NilClass
|
|
45
|
+
redactor.call(value.to_s) == value.to_s ? value : "[REDACTED]"
|
|
46
|
+
else
|
|
47
|
+
# 不调用任意对象的 inspect/to_s,避免展开异常、配置对象或容器中的秘密。
|
|
48
|
+
"[REDACTED]"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def safe_text(text, redactor)
|
|
53
|
+
rendered = TerminalRenderer.render(redactor.call(text))
|
|
54
|
+
redactor.call(rendered).force_encoding(Encoding::UTF_8).scrub.gsub(/[[:cntrl:]]+/, " ").strip
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def token(value)
|
|
58
|
+
return "nil" if value.nil?
|
|
59
|
+
|
|
60
|
+
text = value.to_s
|
|
61
|
+
text.match?(/\A[A-Za-z0-9_.:\/-]+\z/) ? text : text.inspect
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
class Log
|
|
8
|
+
# 只安装在连接器自有的 Logger 上;注入的日志器保留应用自己的格式。
|
|
9
|
+
class Formatter < ::Logger::Formatter
|
|
10
|
+
def call(severity, time, _program, message)
|
|
11
|
+
"[#{time.getlocal.strftime("%Y-%m-%d %H:%M:%S.%L %:z")}] #{severity} #{message}\n"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Net
|
|
4
|
+
module Connector
|
|
5
|
+
class Log
|
|
6
|
+
# 独立于日志目标的人类可读事件文案。
|
|
7
|
+
module Messages
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# 将结构化会话事件转换为便于人工阅读的中文日志。
|
|
11
|
+
def format(name, fields)
|
|
12
|
+
case name
|
|
13
|
+
when "connect"
|
|
14
|
+
"开始连接 #{fields[:host]}(#{fields[:protocol].to_s.upcase},账号 #{fields[:username]})"
|
|
15
|
+
when "connect_failed"
|
|
16
|
+
"连接失败:#{fields[:message]}(#{fields[:error]})"
|
|
17
|
+
when "login_start"
|
|
18
|
+
"等待设备登录提示"
|
|
19
|
+
when "login_output"
|
|
20
|
+
"登录过程回显(已脱敏):"
|
|
21
|
+
when "login_complete"
|
|
22
|
+
fields[:status] == "ok" ? "登录成功,设备提示符:#{fields[:prompt]}" :
|
|
23
|
+
"登录失败:#{fields[:message]}(#{fields[:error]})"
|
|
24
|
+
when "command_start"
|
|
25
|
+
"下发命令:#{fields[:text]}"
|
|
26
|
+
when "device_output"
|
|
27
|
+
"设备回显:"
|
|
28
|
+
when "command_complete"
|
|
29
|
+
fields[:status] == "response_received" ? "命令回显结束,已收到设备提示符" :
|
|
30
|
+
"命令执行失败:#{fields[:message]}(#{fields[:error]})"
|
|
31
|
+
when "operation_start"
|
|
32
|
+
"开始处理设备脚本"
|
|
33
|
+
when "operation_complete"
|
|
34
|
+
fields[:status] == "completed" ? "设备脚本处理完成" :
|
|
35
|
+
"设备脚本处理失败:#{fields[:message]}(#{fields[:error]})"
|
|
36
|
+
when "tftp_backup"
|
|
37
|
+
case fields[:status]
|
|
38
|
+
when "reported_uploaded"
|
|
39
|
+
"TFTP 备份:设备报告上传成功,目标 #{fields[:server]}/#{fields[:path]};服务器文件尚未核验"
|
|
40
|
+
when "transfer_failed"
|
|
41
|
+
"TFTP 备份失败:设备报告传输失败,服务器 #{fields[:server]}"
|
|
42
|
+
else
|
|
43
|
+
"TFTP 备份未确认:设备未报告上传成功,服务器 #{fields[:server]}"
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
name.to_s
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Net
|
|
4
|
+
module Connector
|
|
5
|
+
class Log
|
|
6
|
+
# 每个目标独占 expect-pty 的过滤流;此层只适配 IO,不实现匹配算法。
|
|
7
|
+
class RedactingWriter
|
|
8
|
+
def initialize(target, redactor)
|
|
9
|
+
@target = target
|
|
10
|
+
@redactor = redactor
|
|
11
|
+
@filter = redactor.stream
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def write(bytes)
|
|
15
|
+
@filter.patterns = @redactor.patterns
|
|
16
|
+
@target.write(@filter.append(bytes.b))
|
|
17
|
+
bytes.bytesize
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def flush = @target.flush
|
|
21
|
+
|
|
22
|
+
def finish
|
|
23
|
+
@filter.patterns = @redactor.patterns
|
|
24
|
+
# 配置正文由 Session 的敏感范围隔离;保留完整词匹配的收尾契约。
|
|
25
|
+
@target.write(@filter.finish(partial: false))
|
|
26
|
+
flush
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# 已渲染、已脱敏的字节按行变成事件,半行在上下文切换前收尾。
|
|
31
|
+
class Transcript
|
|
32
|
+
def initialize(&on_line)
|
|
33
|
+
@on_line = on_line
|
|
34
|
+
@buffer = +"".b
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def write(bytes)
|
|
38
|
+
@buffer << bytes
|
|
39
|
+
while (ending = @buffer.index("\n"))
|
|
40
|
+
@on_line.call(@buffer.slice!(0, ending + 1).chomp)
|
|
41
|
+
end
|
|
42
|
+
bytes.bytesize
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def flush; end
|
|
46
|
+
|
|
47
|
+
def finish
|
|
48
|
+
@on_line.call(@buffer) unless @buffer.empty?
|
|
49
|
+
@buffer = +"".b
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private_constant :RedactingWriter, :Transcript
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|