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
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "logger"
|
|
5
|
+
require "securerandom"
|
|
5
6
|
require_relative "terminal_renderer"
|
|
6
|
-
require_relative "
|
|
7
|
+
require_relative "log/event"
|
|
8
|
+
require_relative "log/formatter"
|
|
9
|
+
require_relative "log/stream"
|
|
7
10
|
require_relative "errors"
|
|
11
|
+
require_relative "error_metadata"
|
|
8
12
|
|
|
9
13
|
module Net
|
|
10
14
|
module Connector
|
|
@@ -12,39 +16,56 @@ module Net
|
|
|
12
16
|
class Log
|
|
13
17
|
LEVELS = { debug: ::Logger::DEBUG, info: ::Logger::INFO,
|
|
14
18
|
warn: ::Logger::WARN, error: ::Logger::ERROR }.freeze
|
|
19
|
+
CONTEXT_FIELDS = %w[host session_id operation command_id text source line].freeze
|
|
20
|
+
private_constant :CONTEXT_FIELDS
|
|
15
21
|
|
|
16
22
|
# 保存日志配置和敏感信息脱敏器。
|
|
17
23
|
def initialize(configuration, redactor:)
|
|
18
24
|
@configuration = configuration
|
|
19
25
|
@redactor = redactor
|
|
26
|
+
@context = {}.freeze
|
|
20
27
|
end
|
|
21
28
|
|
|
22
29
|
# 为会话打开日志文件或应用日志器。
|
|
23
30
|
def open(transport)
|
|
24
31
|
@transport = transport
|
|
32
|
+
@session_id = SecureRandom.hex(8).freeze
|
|
33
|
+
@command_sequence = 0
|
|
25
34
|
@io = open_file(@configuration.log_file) if @configuration.log_file
|
|
26
|
-
@output = RedactingWriter.new(@io, @redactor) if @io
|
|
27
35
|
if @configuration.log_format == :raw
|
|
28
|
-
@writer = @output
|
|
36
|
+
@writer = @output = RedactingWriter.new(@io, @redactor) if @io
|
|
29
37
|
elsif @io || @configuration.logger
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
base.formatter = proc do |severity, time, _program, message|
|
|
33
|
-
"[#{time.getlocal.strftime("%Y-%m-%d %H:%M:%S %:z")}] #{severity} #{message}\n"
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
# 前缀直接写入消息,不克隆或修改调用方注入的日志器。
|
|
37
|
-
@logger = base
|
|
38
|
-
@event_level = [base.level, LEVELS.fetch(@configuration.log_level)].max
|
|
39
|
-
@tag = "[host=#{@configuration.host}] "
|
|
40
|
-
if @io && @configuration.log_level == :debug
|
|
41
|
-
@writer = TerminalRenderer.new(@output, max_line_bytes: @configuration.max_output_bytes)
|
|
42
|
-
end
|
|
38
|
+
@logger = @configuration.logger || ::Logger.new(@io, formatter: Formatter.new)
|
|
39
|
+
open_transcript if @io && @configuration.log_level == :debug
|
|
43
40
|
end
|
|
44
|
-
@line_open = false
|
|
45
41
|
rescue => error
|
|
46
42
|
close_preserving_error
|
|
47
|
-
raise
|
|
43
|
+
raise build_error("unable to open session log", error), cause: nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 上下文只在持有会话锁期间使用;收尾必须先于恢复父上下文。
|
|
47
|
+
def with_context(**fields)
|
|
48
|
+
previous = @context
|
|
49
|
+
finish_output
|
|
50
|
+
@context = @context.merge(fields).freeze
|
|
51
|
+
yield
|
|
52
|
+
ensure
|
|
53
|
+
begin
|
|
54
|
+
finish_output
|
|
55
|
+
ensure
|
|
56
|
+
@context = previous
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def with_operation_context(name, &block)
|
|
61
|
+
with_context(operation: name || @context[:operation] || :script, &block)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def with_command_context(command, &block)
|
|
65
|
+
@command_sequence += 1
|
|
66
|
+
with_context(command_id: @command_sequence, phase: :command,
|
|
67
|
+
text: command.sensitive? ? "[REDACTED]" : command.text,
|
|
68
|
+
source: command.source, line: command.line, &block)
|
|
48
69
|
end
|
|
49
70
|
|
|
50
71
|
# 将终端回显接入当前会话日志。
|
|
@@ -56,60 +77,75 @@ module Net
|
|
|
56
77
|
end
|
|
57
78
|
|
|
58
79
|
# 判断当前日志级别是否需要完整设备回显。
|
|
59
|
-
def
|
|
80
|
+
def debug? = @configuration.log_level == :debug && (!!@writer || enabled?(:debug))
|
|
60
81
|
|
|
61
82
|
# 按日志级别写入已脱敏的业务事件。
|
|
62
|
-
def
|
|
83
|
+
def log_event(name, level: :info, **fields)
|
|
63
84
|
return unless @logger
|
|
64
85
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
end
|
|
68
|
-
safe_name = @redactor.call(name.to_s).scrub.gsub(/[[:cntrl:]]+/, " ").strip
|
|
69
|
-
finish_line
|
|
70
|
-
@logger.public_send(level, "#{@tag}#{LogMessages.format(safe_name, values)}") if LEVELS.fetch(level) >= @event_level
|
|
86
|
+
finish_output
|
|
87
|
+
write_event(name, level: level, **fields)
|
|
71
88
|
rescue Error
|
|
72
89
|
raise
|
|
73
90
|
rescue => error
|
|
74
|
-
raise
|
|
91
|
+
raise build_error("unable to write session event", error), cause: nil
|
|
75
92
|
end
|
|
76
93
|
|
|
77
|
-
#
|
|
78
|
-
def
|
|
79
|
-
|
|
94
|
+
# 异常正文由会话先脱敏;类型、错误码和阶段只允许共享词表中的值进入日志。
|
|
95
|
+
def log_failure(name, failure, phase: nil, **fields)
|
|
96
|
+
log_event(name, level: :error, **fields,
|
|
97
|
+
error: ErrorMetadata.type(failure.class.name) || "StandardError",
|
|
98
|
+
code: ErrorMetadata.code(failure.code),
|
|
99
|
+
phase: ErrorMetadata.phase(failure.phase) || ErrorMetadata.phase(phase),
|
|
100
|
+
message: failure.message)
|
|
101
|
+
end
|
|
80
102
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
103
|
+
# 用户钩子可能把未登记的配置片段放进事件名、字段名或值;敏感范围统一隐藏。
|
|
104
|
+
def log_custom_event(name, level: :info, **fields)
|
|
105
|
+
if @redactor.sensitive?
|
|
106
|
+
log_event("custom", level: level, details: "[REDACTED]")
|
|
85
107
|
else
|
|
86
|
-
|
|
108
|
+
log_event(name, level: level, **fields)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# 登录完成并收集动态口令后,再记录已脱敏的认证回显。
|
|
113
|
+
def log_authentication_output(bytes)
|
|
114
|
+
return unless debug?
|
|
115
|
+
|
|
116
|
+
with_context(phase: :login) do
|
|
117
|
+
log_event("login_output", level: :debug)
|
|
118
|
+
if @writer
|
|
119
|
+
write(@redactor.call(bytes))
|
|
120
|
+
flush
|
|
121
|
+
else
|
|
122
|
+
log_response_output(bytes)
|
|
123
|
+
end
|
|
87
124
|
end
|
|
88
125
|
end
|
|
89
126
|
|
|
90
|
-
#
|
|
91
|
-
def
|
|
92
|
-
return unless
|
|
127
|
+
# 注入日志器与文件回显共用事件格式;两次脱敏覆盖终端控制符拼接。
|
|
128
|
+
def log_response_output(bytes)
|
|
129
|
+
return unless debug? && !@writer && !@redactor.output_sensitive?
|
|
93
130
|
|
|
94
131
|
@redactor.call(TerminalRenderer.render(@redactor.call(bytes))).each_line do |line|
|
|
95
|
-
|
|
132
|
+
write_event("device_output", level: :debug, output: line.chomp)
|
|
96
133
|
end
|
|
97
134
|
rescue Error
|
|
98
135
|
raise
|
|
99
136
|
rescue => error
|
|
100
|
-
raise
|
|
137
|
+
raise build_error("unable to write device output", error), cause: nil
|
|
101
138
|
end
|
|
102
139
|
|
|
103
140
|
# 向终端日志写入已处理的回显字节。
|
|
104
141
|
def write(bytes)
|
|
105
|
-
return unless @writer
|
|
142
|
+
return unless @writer && !@redactor.output_sensitive?
|
|
106
143
|
|
|
107
144
|
@writer.write(bytes)
|
|
108
|
-
@line_open = !bytes.end_with?("\n") unless bytes.empty?
|
|
109
145
|
rescue Error
|
|
110
146
|
raise
|
|
111
147
|
rescue => error
|
|
112
|
-
raise
|
|
148
|
+
raise build_error("unable to write session log", error), cause: nil
|
|
113
149
|
end
|
|
114
150
|
|
|
115
151
|
# 刷新日志缓冲区。
|
|
@@ -119,7 +155,7 @@ module Net
|
|
|
119
155
|
rescue Error
|
|
120
156
|
raise
|
|
121
157
|
rescue => error
|
|
122
|
-
raise
|
|
158
|
+
raise build_error("unable to flush session log", error), cause: nil
|
|
123
159
|
end
|
|
124
160
|
|
|
125
161
|
# 执行代码块期间暂停自动记录传输回显。
|
|
@@ -140,62 +176,48 @@ module Net
|
|
|
140
176
|
begin
|
|
141
177
|
@io&.close
|
|
142
178
|
ensure
|
|
143
|
-
@writer = @output = @
|
|
179
|
+
@writer = @output = @transcript = @logger = @io = @transport = @session_id = nil
|
|
144
180
|
@attached = false
|
|
145
181
|
end
|
|
146
182
|
end
|
|
147
183
|
rescue Error
|
|
148
184
|
raise
|
|
149
185
|
rescue => error
|
|
150
|
-
raise
|
|
186
|
+
raise build_error("unable to close session log", error), cause: nil
|
|
151
187
|
end
|
|
152
188
|
|
|
153
189
|
private
|
|
154
190
|
|
|
155
|
-
#
|
|
156
|
-
def
|
|
157
|
-
|
|
191
|
+
# 每次读取调用方的当前阈值,不缓存级别、不改写共享 Logger。
|
|
192
|
+
def enabled?(level)
|
|
193
|
+
@logger && LEVELS.fetch(level) >= [@logger.level, LEVELS.fetch(@configuration.log_level)].max
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def write_event(name, level:, **fields)
|
|
197
|
+
return unless enabled?(level)
|
|
158
198
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
@
|
|
199
|
+
values = @context.compact.merge(fields.reject { |key, _| CONTEXT_FIELDS.include?(key.to_s) })
|
|
200
|
+
values = values.merge(host: @configuration.host, session_id: @session_id)
|
|
201
|
+
@logger.public_send(level, Event.new(name, values, redactor: @redactor))
|
|
162
202
|
end
|
|
163
203
|
|
|
164
|
-
|
|
204
|
+
def open_transcript
|
|
205
|
+
@transcript = Transcript.new { |line| write_event("device_output", level: :debug, output: line) }
|
|
206
|
+
@output = RedactingWriter.new(@transcript, @redactor)
|
|
207
|
+
@writer = TerminalRenderer.new(@output, max_line_bytes: @configuration.max_output_bytes)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# 先渲染,再结束过滤流,最后发出半行事件;顺序不能反转。
|
|
165
211
|
def finish_output
|
|
166
212
|
@writer.finish if @writer.respond_to?(:finish)
|
|
167
213
|
@output&.finish unless @output.equal?(@writer)
|
|
214
|
+
@transcript&.finish
|
|
215
|
+
rescue Error
|
|
216
|
+
raise
|
|
217
|
+
rescue => error
|
|
218
|
+
raise build_error("unable to finish session output", error), cause: nil
|
|
168
219
|
end
|
|
169
220
|
|
|
170
|
-
# 文件写入边界保留短尾部,避免分片或终端控制符拼出明文凭据。
|
|
171
|
-
class RedactingWriter
|
|
172
|
-
# 保存目标流、脱敏器和等待下一分片的尾部。
|
|
173
|
-
def initialize(target, redactor)
|
|
174
|
-
@target = target
|
|
175
|
-
@redactor = redactor
|
|
176
|
-
@pending = "".b
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
# 写入已确认安全的前缀,暂存可能与下一分片组成秘密的尾部。
|
|
180
|
-
def write(bytes)
|
|
181
|
-
@pending << bytes.b
|
|
182
|
-
safe, @pending = @redactor.stream_chunk(@pending)
|
|
183
|
-
@target.write(safe)
|
|
184
|
-
bytes.bytesize
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
# 刷新目标流,仍不提前写出待判断的尾部。
|
|
188
|
-
def flush = @target.flush
|
|
189
|
-
|
|
190
|
-
# 在日志结束时脱敏并写出剩余尾部。
|
|
191
|
-
def finish
|
|
192
|
-
safe, @pending = @redactor.stream_chunk(@pending, final: true)
|
|
193
|
-
@target.write(safe)
|
|
194
|
-
flush
|
|
195
|
-
end
|
|
196
|
-
end
|
|
197
|
-
private_constant :RedactingWriter
|
|
198
|
-
|
|
199
221
|
# 以私有权限打开设备日志文件。
|
|
200
222
|
def open_file(path)
|
|
201
223
|
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
@@ -223,8 +245,9 @@ module Net
|
|
|
223
245
|
end
|
|
224
246
|
|
|
225
247
|
# 将日志异常包装成统一错误并保留脱敏原因。
|
|
226
|
-
def
|
|
227
|
-
LogError.new(message, phase: :logging,
|
|
248
|
+
def build_error(message, error)
|
|
249
|
+
LogError.new(message, phase: :logging,
|
|
250
|
+
underlying: UnderlyingError.new(error, @redactor, sensitive: @redactor.sensitive?))
|
|
228
251
|
end
|
|
229
252
|
end
|
|
230
253
|
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
|
# 创建认证、响应读取、脱敏和日志对象,并把会话置于关闭状态。
|
|
@@ -38,7 +40,22 @@ module Net
|
|
|
38
40
|
def mark_privileged! = @privileged = true
|
|
39
41
|
|
|
40
42
|
# 记录脚本之外的业务结果,例如设备报告的 TFTP 上传状态。
|
|
41
|
-
def log_event(name, **fields) = @log.
|
|
43
|
+
def log_event(name, **fields) = @log.log_custom_event(name, **fields)
|
|
44
|
+
|
|
45
|
+
# 业务名称贯穿脚本及其钩子,底层命令另外分配独立序号。
|
|
46
|
+
def log_script(operation:, steps:)
|
|
47
|
+
@log.with_operation_context(operation) do
|
|
48
|
+
started = Expect.monotonic
|
|
49
|
+
@log.log_event("operation_start", level: :debug, phase: :script)
|
|
50
|
+
result = yield
|
|
51
|
+
log_operation_completion(started, result.is_a?(Result) ? result.error : nil, steps: steps.size)
|
|
52
|
+
result
|
|
53
|
+
rescue => error
|
|
54
|
+
failure = normalize_error(error, phase: :script)
|
|
55
|
+
log_operation_completion(started, failure, steps: steps.size)
|
|
56
|
+
raise failure, cause: nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
42
59
|
|
|
43
60
|
# 在会话锁内建立连接。
|
|
44
61
|
def connect
|
|
@@ -51,7 +68,7 @@ module Net
|
|
|
51
68
|
return perform_locked(phase) { yield }
|
|
52
69
|
end
|
|
53
70
|
unless @lock.try_lock
|
|
54
|
-
raise
|
|
71
|
+
raise build_error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
|
|
55
72
|
end
|
|
56
73
|
|
|
57
74
|
begin
|
|
@@ -64,17 +81,31 @@ module Net
|
|
|
64
81
|
# 业务操作在多次脚本之间持有租约;只有同一线程和 Fiber 可顺序使用。
|
|
65
82
|
def with_operation(phase)
|
|
66
83
|
unless @lock.try_lock
|
|
67
|
-
raise
|
|
84
|
+
raise build_error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
|
|
68
85
|
end
|
|
69
86
|
@operation_owner = [Thread.current, Fiber.current]
|
|
87
|
+
result = nil
|
|
70
88
|
begin
|
|
71
|
-
yield
|
|
89
|
+
@log.with_operation_context(phase) { result = yield }
|
|
90
|
+
rescue Error => error
|
|
91
|
+
raise unless result.is_a?(Result)
|
|
92
|
+
|
|
93
|
+
# 日志收尾失败不能抹去已完成的设备步骤,也不能覆盖原业务错误。
|
|
94
|
+
Result.new(steps: result.steps, config: result.config,
|
|
95
|
+
error: result.error || normalize_error(error, phase: phase))
|
|
72
96
|
ensure
|
|
73
97
|
@operation_owner = nil
|
|
74
98
|
@lock.unlock
|
|
75
99
|
end
|
|
76
100
|
end
|
|
77
101
|
|
|
102
|
+
# 文件业务必须先取得路径锁,再调用会话;不能持有会话租约去等待另一个备份者。
|
|
103
|
+
def assert_path_lock_order!(phase)
|
|
104
|
+
return unless @lock.locked?
|
|
105
|
+
|
|
106
|
+
raise build_error(SessionBusy, "backup path ownership must be acquired before a session operation", phase: phase), cause: nil
|
|
107
|
+
end
|
|
108
|
+
|
|
78
109
|
# 一次操作从连接到结果处理始终持锁;异常或 throw 中断都关闭未完成会话。
|
|
79
110
|
def perform_locked(phase)
|
|
80
111
|
@performing = true
|
|
@@ -109,7 +140,7 @@ module Net
|
|
|
109
140
|
# 独占关闭会话;已有操作占用时返回会话繁忙错误。
|
|
110
141
|
def close
|
|
111
142
|
unless @lock.try_lock
|
|
112
|
-
raise
|
|
143
|
+
raise build_error(SessionBusy, "cannot close a session owned by another operation", phase: :close), cause: nil
|
|
113
144
|
end
|
|
114
145
|
|
|
115
146
|
begin
|
|
@@ -122,7 +153,7 @@ module Net
|
|
|
122
153
|
# 执行特权认证,并更新提示符及特权状态。
|
|
123
154
|
def enable(command, prompt)
|
|
124
155
|
unless command
|
|
125
|
-
raise
|
|
156
|
+
raise build_error(UnsupportedOperation, "privilege authentication is not supported", phase: :enable), cause: nil
|
|
126
157
|
end
|
|
127
158
|
|
|
128
159
|
@privileged = false
|
|
@@ -133,38 +164,71 @@ module Net
|
|
|
133
164
|
end
|
|
134
165
|
|
|
135
166
|
# 命令准备、交换、后处理及回调共用一份临时词表,退出时一并清除。
|
|
136
|
-
def
|
|
137
|
-
redactor.
|
|
167
|
+
def with_command_redaction(command)
|
|
168
|
+
redactor.with_scope do
|
|
138
169
|
protect_command(command)
|
|
139
170
|
yield
|
|
140
171
|
end
|
|
141
172
|
end
|
|
142
173
|
|
|
174
|
+
# 批次准备和最终清理也可能读取配置;在作用域退出前归一化其异常,
|
|
175
|
+
# 防止清理钩子的消息或回溯将已完成步骤中的正文带回诊断通道。
|
|
176
|
+
def with_sensitive_output(enabled)
|
|
177
|
+
return yield unless enabled
|
|
178
|
+
|
|
179
|
+
redactor.with_scope do
|
|
180
|
+
redactor.output_sensitive!
|
|
181
|
+
yield
|
|
182
|
+
rescue => error
|
|
183
|
+
raise normalize_error(error, phase: :script), cause: nil
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
143
187
|
# 复用完整命令的脱敏范围;厂商后续查询产生的秘密保留到外层回调结束。
|
|
144
|
-
def
|
|
145
|
-
redactor.
|
|
188
|
+
def execute_command(command, timeout:, prompt: nil)
|
|
189
|
+
redactor.with_scope(reuse: true) do
|
|
146
190
|
protect_command(command)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
response = exchange_command(command, timeout: timeout, prompt: prompt)
|
|
152
|
-
@log.response_output(response.raw) unless command.sensitive? || sensitive_dialogue
|
|
153
|
-
@log.event("command_complete", status: "response_received")
|
|
154
|
-
@log.event("command_detail", level: :debug,
|
|
155
|
-
duration_ms: ((Expect.monotonic - started) * 1000).round,
|
|
156
|
-
response_bytes: response.raw.bytesize)
|
|
157
|
-
response
|
|
158
|
-
rescue => error
|
|
159
|
-
failure = normalize_error(error, phase: :command, command: command)
|
|
160
|
-
@log.event("command_complete", level: :error, status: "failed",
|
|
161
|
-
error: failure.class.name, message: failure.message)
|
|
162
|
-
raise failure, cause: nil
|
|
191
|
+
@log.with_command_context(command) { execute_command_with_logging(command, timeout: timeout, prompt: prompt) }
|
|
192
|
+
ensure
|
|
193
|
+
# 失败的敏感探测也可能被钩子捕获后继续;离开作用域前始终通知执行器。
|
|
194
|
+
yield if block_given?
|
|
163
195
|
end
|
|
164
196
|
end
|
|
165
197
|
|
|
198
|
+
def execute_command_with_logging(command, timeout:, prompt:)
|
|
199
|
+
started = Expect.monotonic
|
|
200
|
+
@log.log_event("command_start")
|
|
201
|
+
sensitive_dialogue = [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
|
|
202
|
+
private_output = command.sensitive? || redactor.output_sensitive? || sensitive_dialogue
|
|
203
|
+
@log.log_event("device_output", level: :debug) if @log.debug? && !private_output
|
|
204
|
+
response = exchange_command(command, timeout: timeout, prompt: prompt)
|
|
205
|
+
@log.log_response_output(response.raw) unless private_output
|
|
206
|
+
details = { duration_ms: elapsed_ms(started), response_bytes: response.raw.bytesize }
|
|
207
|
+
@log.log_event("command_complete", status: "response_received", **details)
|
|
208
|
+
response
|
|
209
|
+
rescue => error
|
|
210
|
+
failure = normalize_error(error, phase: :command, command: command)
|
|
211
|
+
@log.log_failure("command_complete", failure, status: "failed", duration_ms: elapsed_ms(started), phase: :command)
|
|
212
|
+
raise failure, cause: nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def elapsed_ms(started) = ((Expect.monotonic - started) * 1000).round
|
|
216
|
+
|
|
217
|
+
def log_operation_completion(started, failure, steps: nil)
|
|
218
|
+
fields = { status: failure ? "failed" : "completed", duration_ms: elapsed_ms(started), steps: steps,
|
|
219
|
+
phase: :script }
|
|
220
|
+
if failure
|
|
221
|
+
@log.log_failure("operation_complete", failure, **fields, failed_command: failure.command)
|
|
222
|
+
else
|
|
223
|
+
@log.log_event("operation_complete", **fields)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private :execute_command_with_logging, :elapsed_ms, :log_operation_completion
|
|
228
|
+
|
|
166
229
|
# 在用户钩子运行前标记敏感上下文,动态交互尚未返回时也能保护其异常。
|
|
167
230
|
def protect_command(command)
|
|
231
|
+
redactor.output_sensitive! if command.output_sensitive?
|
|
168
232
|
if command.sensitive? || [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
|
|
169
233
|
redactor.sensitive!
|
|
170
234
|
end
|
|
@@ -184,12 +248,12 @@ module Net
|
|
|
184
248
|
deadline: deadline, phase: :command, command: command)
|
|
185
249
|
@prompt = response.prompt
|
|
186
250
|
if (diagnostic = @dialogue.diagnostic(response.raw))
|
|
187
|
-
raise
|
|
251
|
+
raise build_error(DeviceError, diagnostic, phase: :command, command: command, output: response.raw), cause: nil
|
|
188
252
|
end
|
|
189
253
|
|
|
190
254
|
response
|
|
191
255
|
end
|
|
192
|
-
if command.sensitive? || interactions.any?(&:sensitive?)
|
|
256
|
+
if command.sensitive? || redactor.output_sensitive? || interactions.any?(&:sensitive?)
|
|
193
257
|
@log.pause(&operation)
|
|
194
258
|
else
|
|
195
259
|
operation.call
|
|
@@ -219,33 +283,33 @@ module Net
|
|
|
219
283
|
end
|
|
220
284
|
|
|
221
285
|
# 创建带脱敏上下文、阶段、命令和输出尾部的领域错误。
|
|
222
|
-
def
|
|
223
|
-
sensitive = redactor.sensitive? || command&.sensitive?
|
|
286
|
+
def build_error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
|
|
287
|
+
sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
|
|
224
288
|
message = safe_error_text(message, sensitive: sensitive)
|
|
225
289
|
output = safe_error_text(output, sensitive: sensitive)
|
|
226
290
|
klass.new(message, host: configuration.host, phase: phase,
|
|
227
291
|
command: command && (command.sensitive? ? "[REDACTED]" : redactor.call(command.text)),
|
|
228
292
|
source: command&.source, line: command&.line,
|
|
229
|
-
output: output
|
|
293
|
+
output: truncate_output(output),
|
|
230
294
|
underlying: underlying && UnderlyingError.new(underlying, redactor, sensitive: sensitive), **context)
|
|
231
295
|
end
|
|
232
296
|
|
|
233
297
|
# 将底层异常映射为连接、传输、超时或内部错误,并保留安全上下文。
|
|
234
298
|
def normalize_error(exception, phase:, command: nil)
|
|
235
299
|
if exception.is_a?(Error)
|
|
236
|
-
sensitive = redactor.sensitive? || command&.sensitive?
|
|
300
|
+
sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
|
|
237
301
|
failed_command = exception.command || command&.text
|
|
238
302
|
if command&.sensitive? || (sensitive && exception.command && exception.command != command&.text)
|
|
239
303
|
failed_command = "[REDACTED]"
|
|
240
304
|
end
|
|
241
305
|
message = safe_error_text(exception.message, sensitive: sensitive)
|
|
242
306
|
output = safe_error_text(exception.output, sensitive: sensitive)
|
|
243
|
-
return exception.
|
|
244
|
-
message
|
|
307
|
+
return exception.with_diagnostics(
|
|
308
|
+
message: message,
|
|
245
309
|
host: exception.host || configuration.host, phase: exception.phase || phase,
|
|
246
310
|
command: failed_command && redactor.call(failed_command),
|
|
247
311
|
source: exception.source || command&.source, line: exception.line || command&.line,
|
|
248
|
-
output: output
|
|
312
|
+
output: truncate_output(output),
|
|
249
313
|
underlying: exception.underlying && UnderlyingError.new(exception.underlying, redactor, sensitive: sensitive)
|
|
250
314
|
)
|
|
251
315
|
end
|
|
@@ -255,7 +319,7 @@ module Net
|
|
|
255
319
|
when IOError, SystemCallError, Expect::SpawnError then TransportError
|
|
256
320
|
else InternalError
|
|
257
321
|
end
|
|
258
|
-
|
|
322
|
+
build_error(klass, "#{phase} failed: #{exception.class}", phase: phase, command: command, underlying: exception)
|
|
259
323
|
end
|
|
260
324
|
|
|
261
325
|
# 敏感上下文的任意异常和设备输出可能只包含局部秘密,保留类型和错误码诊断。
|
|
@@ -265,6 +329,13 @@ module Net
|
|
|
265
329
|
|
|
266
330
|
private :safe_error_text
|
|
267
331
|
|
|
332
|
+
# 必须先脱敏再截取尾部,避免截断凭据后逃过完整词匹配。
|
|
333
|
+
def truncate_output(text)
|
|
334
|
+
text.byteslice(-MAX_ERROR_OUTPUT_BYTES, MAX_ERROR_OUTPUT_BYTES) || text
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
private :truncate_output
|
|
338
|
+
|
|
268
339
|
# 返回不包含凭据的会话状态摘要。
|
|
269
340
|
def inspect = "#<#{self.class} state=#{state} host=#{configuration.host.inspect}>"
|
|
270
341
|
|
|
@@ -275,27 +346,28 @@ module Net
|
|
|
275
346
|
attempted_recovery = false
|
|
276
347
|
begin
|
|
277
348
|
@state = :connecting
|
|
349
|
+
started = Expect.monotonic
|
|
278
350
|
redactor.reset
|
|
279
351
|
@log.open(transport)
|
|
280
|
-
@log.
|
|
281
|
-
protocol: transport.respond_to?(:protocol) ? transport.protocol : configuration.protocol)
|
|
352
|
+
@log.log_event("connect", host: configuration.host, username: configuration.username,
|
|
353
|
+
phase: :connect, protocol: transport.respond_to?(:protocol) ? transport.protocol : configuration.protocol)
|
|
282
354
|
transport.open
|
|
283
355
|
@state = :authenticating
|
|
284
|
-
@log.
|
|
356
|
+
@log.log_event("login_start", level: :debug, phase: :login)
|
|
285
357
|
response = @authentication.login
|
|
286
358
|
safe_response = Response.new(raw: redactor.call(response.raw), output: redactor.call(response.output),
|
|
287
359
|
prompt: response.prompt)
|
|
288
360
|
@prompt = response.prompt
|
|
289
361
|
@after_login.call(self, safe_response)
|
|
290
|
-
@log.
|
|
291
|
-
@log.
|
|
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))
|
|
292
364
|
@log.attach
|
|
293
365
|
@state = :ready
|
|
294
366
|
rescue => error
|
|
295
367
|
phase = (@state == :connecting) ? :connect : :login
|
|
296
368
|
failure = normalize_error(error, phase: phase)
|
|
297
|
-
@log.
|
|
298
|
-
|
|
369
|
+
@log.log_failure(phase == :connect ? "connect_failed" : "login_complete", failure,
|
|
370
|
+
status: "failed", phase: phase, duration_ms: elapsed_ms(started))
|
|
299
371
|
close_preserving_failure
|
|
300
372
|
replacement = attempted_recovery ? nil : @recovery.recover(failure, transport)
|
|
301
373
|
if replacement
|
|
@@ -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
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "terminal_renderer"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
# 设备/文件提供的是字节,不受 Ruby 源文件编码控制。解析边界只接受 UTF-8,
|
|
9
|
+
# 不猜测编码、不改写原文,也不让终端编辑把非法字节抹掉后变成可信证据。
|
|
10
|
+
module TerminalText
|
|
11
|
+
def self.utf8(bytes, host: nil)
|
|
12
|
+
raise ArgumentError, "text must be a String" unless bytes.is_a?(String)
|
|
13
|
+
|
|
14
|
+
text = bytes.b.force_encoding(Encoding::UTF_8)
|
|
15
|
+
invalid_encoding!(host) unless text.valid_encoding?
|
|
16
|
+
text
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.render(bytes, host: nil)
|
|
20
|
+
TerminalRenderer.render(utf8(bytes, host: host), strict_utf8: true).force_encoding(Encoding::UTF_8)
|
|
21
|
+
rescue Encoding::InvalidByteSequenceError
|
|
22
|
+
invalid_encoding!(host)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.invalid_encoding!(host)
|
|
26
|
+
raise ParsingError.new("device output is not valid UTF-8", code: :invalid_output_encoding,
|
|
27
|
+
host: host, phase: :parse), cause: nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private_class_method :invalid_encoding!
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|