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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/CONTRIBUTING.md +40 -0
  4. data/README.md +93 -18
  5. data/SECURITY.md +11 -0
  6. data/docs/VERIFICATION.md +139 -6
  7. data/docs/architecture.md +248 -39
  8. data/lib/net/connector/device/base.rb +28 -101
  9. data/lib/net/connector/device/local_backup.rb +96 -0
  10. data/lib/net/connector/device/profile.rb +8 -3
  11. data/lib/net/connector/device/running_config/strategy.rb +1 -1
  12. data/lib/net/connector/device/running_config.rb +46 -5
  13. data/lib/net/connector/device/save_config.rb +21 -0
  14. data/lib/net/connector/device/tftp/file_upload.rb +53 -0
  15. data/lib/net/connector/device/tftp/receipt.rb +71 -0
  16. data/lib/net/connector/device/tftp/strategy.rb +67 -0
  17. data/lib/net/connector/device/tftp/target.rb +63 -0
  18. data/lib/net/connector/device/tftp.rb +117 -0
  19. data/lib/net/connector/device/topology/immediate_strategy.rb +40 -0
  20. data/lib/net/connector/device/topology/interface_description.rb +46 -0
  21. data/lib/net/connector/device/topology/interface_name.rb +58 -0
  22. data/lib/net/connector/device/topology/strategy.rb +75 -0
  23. data/lib/net/connector/device/topology.rb +277 -0
  24. data/lib/net/connector/engine/authentication.rb +1 -1
  25. data/lib/net/connector/engine/command.rb +14 -2
  26. data/lib/net/connector/engine/configuration.rb +11 -8
  27. data/lib/net/connector/engine/dialogue.rb +59 -35
  28. data/lib/net/connector/engine/error_metadata.rb +50 -0
  29. data/lib/net/connector/engine/errors.rb +55 -56
  30. data/lib/net/connector/engine/execution.rb +44 -10
  31. data/lib/net/connector/engine/log/event.rb +66 -0
  32. data/lib/net/connector/engine/log/formatter.rb +16 -0
  33. data/lib/net/connector/engine/log/messages.rb +52 -0
  34. data/lib/net/connector/engine/log/stream.rb +56 -0
  35. data/lib/net/connector/engine/log.rb +107 -84
  36. data/lib/net/connector/engine/session.rb +115 -43
  37. data/lib/net/connector/engine/terminal_renderer.rb +7 -4
  38. data/lib/net/connector/engine/terminal_text.rb +33 -0
  39. data/lib/net/connector/engine/transport.rb +2 -2
  40. data/lib/net/connector/netdisco/batch.rb +25 -33
  41. data/lib/net/connector/netdisco/cli.rb +71 -31
  42. data/lib/net/connector/netdisco/client.rb +206 -73
  43. data/lib/net/connector/netdisco/config_file.rb +26 -4
  44. data/lib/net/connector/netdisco/device.rb +4 -5
  45. data/lib/net/connector/netdisco/diagnostic.rb +61 -0
  46. data/lib/net/connector/netdisco/fleet.rb +96 -66
  47. data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
  48. data/lib/net/connector/netdisco/report.rb +115 -0
  49. data/lib/net/connector/netdisco/result_store.rb +2 -2
  50. data/lib/net/connector/netdisco/rules.rb +28 -5
  51. data/lib/net/connector/netdisco/settings.rb +188 -85
  52. data/lib/net/connector/netdisco/worker.rb +26 -17
  53. data/lib/net/connector/storage/backup_lock.rb +116 -0
  54. data/lib/net/connector/storage/private_file.rb +109 -0
  55. data/lib/net/connector/storage/safe_file.rb +62 -0
  56. data/lib/net/connector/{operations → storage}/saved_config.rb +25 -20
  57. data/lib/net/connector/storage.rb +15 -0
  58. data/lib/net/connector/textfsm.rb +72 -0
  59. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +12 -5
  60. data/lib/net/connector/vendor/cisco_ios/topology.rb +12 -6
  61. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +11 -4
  62. data/lib/net/connector/vendor/h3c/tftp_backup.rb +9 -4
  63. data/lib/net/connector/vendor/h3c/topology.rb +11 -6
  64. data/lib/net/connector/vendor/h3c.rb +3 -3
  65. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +15 -6
  66. data/lib/net/connector/vendor/hillstone/topology.rb +11 -6
  67. data/lib/net/connector/vendor/huawei/tftp_backup.rb +9 -3
  68. data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -1
  69. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +17 -8
  70. data/lib/net/connector/vendor/palo_alto/topology.rb +9 -7
  71. data/lib/net/connector/vendor/radware/tftp_backup.rb +12 -5
  72. data/lib/net/connector/vendor/radware/topology.rb +3 -2
  73. data/lib/net/connector/version.rb +1 -1
  74. data/lib/net/connector.rb +1 -6
  75. metadata +54 -38
  76. data/lib/net/connector/device/interface_description.rb +0 -44
  77. data/lib/net/connector/device/interface_name.rb +0 -56
  78. data/lib/net/connector/engine/base.rb +0 -4
  79. data/lib/net/connector/engine/log_messages.rb +0 -47
  80. data/lib/net/connector/engine/profile.rb +0 -4
  81. data/lib/net/connector/engine.rb +0 -4
  82. data/lib/net/connector/operations/local_backup.rb +0 -49
  83. data/lib/net/connector/operations/parse_output.rb +0 -60
  84. data/lib/net/connector/operations/private_file.rb +0 -25
  85. data/lib/net/connector/operations/running_config/cisco.rb +0 -14
  86. data/lib/net/connector/operations/running_config/hillstone.rb +0 -12
  87. data/lib/net/connector/operations/running_config/palo_alto.rb +0 -12
  88. data/lib/net/connector/operations/running_config/strategy.rb +0 -3
  89. data/lib/net/connector/operations/running_config.rb +0 -16
  90. data/lib/net/connector/operations/tftp/cisco_ios.rb +0 -11
  91. data/lib/net/connector/operations/tftp/cisco_nxos.rb +0 -11
  92. data/lib/net/connector/operations/tftp/file_upload.rb +0 -42
  93. data/lib/net/connector/operations/tftp/h3c.rb +0 -11
  94. data/lib/net/connector/operations/tftp/hillstone.rb +0 -11
  95. data/lib/net/connector/operations/tftp/huawei.rb +0 -11
  96. data/lib/net/connector/operations/tftp/palo_alto.rb +0 -11
  97. data/lib/net/connector/operations/tftp/radware.rb +0 -11
  98. data/lib/net/connector/operations/tftp/strategy.rb +0 -75
  99. data/lib/net/connector/operations/tftp_backup.rb +0 -120
  100. data/lib/net/connector/operations/topology/cisco.rb +0 -11
  101. data/lib/net/connector/operations/topology/h3c.rb +0 -11
  102. data/lib/net/connector/operations/topology/hillstone.rb +0 -11
  103. data/lib/net/connector/operations/topology/palo_alto.rb +0 -11
  104. data/lib/net/connector/operations/topology/radware.rb +0 -11
  105. data/lib/net/connector/operations/topology/strategy.rb +0 -67
  106. data/lib/net/connector/operations/topology.rb +0 -193
  107. 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 "log_messages"
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
- base = @configuration.logger || ::Logger.new(@io)
31
- unless @configuration.logger
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 failure("unable to open session log", error), cause: nil
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 detailed? = @configuration.log_level == :debug && !!(@writer || @logger)
80
+ def debug? = @configuration.log_level == :debug && (!!@writer || enabled?(:debug))
60
81
 
61
82
  # 按日志级别写入已脱敏的业务事件。
62
- def event(name, level: :info, **fields)
83
+ def log_event(name, level: :info, **fields)
63
84
  return unless @logger
64
85
 
65
- values = fields.transform_values do |value|
66
- value.is_a?(String) ? @redactor.call(value).scrub.gsub(/[[:cntrl:]]+/, " ").strip : value
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 failure("unable to write session event", error), cause: nil
91
+ raise build_error("unable to write session event", error), cause: nil
75
92
  end
76
93
 
77
- # 登录完成并收集动态口令后,再记录已脱敏的认证回显。
78
- def authentication_output(bytes)
79
- return unless detailed?
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
- event("login_output", level: :debug)
82
- if @writer
83
- write(@redactor.call(bytes))
84
- flush
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
- response_output(bytes)
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 response_output(bytes)
92
- return unless detailed? && !@writer
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
- @logger.debug("#{@tag} #{line.chomp}") if ::Logger::DEBUG >= @event_level
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 failure("unable to write device output", error), cause: nil
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 failure("unable to write session log", error), cause: nil
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 failure("unable to flush session log", error), cause: nil
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 = @logger = @io = @transport = @tag = @event_level = nil
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 failure("unable to close session log", error), cause: nil
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 finish_line
157
- return unless @writer
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
- finish_output
160
- @io.write("\n") if @line_open
161
- @line_open = false
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 failure(message, error)
227
- LogError.new(message, phase: :logging, underlying: UnderlyingError.new(error, @redactor))
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.event(name, **fields)
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 error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
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 error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
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 error(SessionBusy, "cannot close a session owned by another operation", phase: :close), cause: nil
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 error(UnsupportedOperation, "privilege authentication is not supported", phase: :enable), cause: nil
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 command_scope(command)
137
- redactor.scope do
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 exchange(command, timeout:, prompt: nil)
145
- redactor.scope(reuse: true) do
188
+ def execute_command(command, timeout:, prompt: nil)
189
+ redactor.with_scope(reuse: true) do
146
190
  protect_command(command)
147
- started = Expect.monotonic
148
- @log.event("command_start", text: command.sensitive? ? "[REDACTED]" : command.text)
149
- sensitive_dialogue = [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
150
- @log.event("device_output", level: :debug) if @log.detailed? && !command.sensitive? && !sensitive_dialogue
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 error(DeviceError, diagnostic, phase: :command, command: command, output: response.raw), cause: nil
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 error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
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.byteslice(-4096, 4096) || 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.class.new(
244
- message, code: exception.code,
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.byteslice(-4096, 4096) || 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
- error(klass, "#{phase} failed: #{exception.class}", phase: phase, command: command, underlying: exception)
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.event("connect", host: configuration.host, username: configuration.username,
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.event("login_start", level: :debug)
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.authentication_output(response.raw)
291
- @log.event("login_complete", status: "ok", prompt: response.prompt)
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.event(phase == :connect ? "connect_failed" : "login_complete", level: :error,
298
- status: "failed", error: failure.class.name, message: failure.message, phase: phase)
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
- # 尽量保持有效 UTF-8;非法字节转义为可读的十六进制文本。
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