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
@@ -130,62 +130,86 @@ module Net
130
130
  counts = Hash.new(0)
131
131
  bytes = 0
132
132
  loop do
133
- event = @session.transport.read(patterns, timeout: [deadline - Expect.monotonic, 0].max)
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
- if bytes > @session.configuration.max_output_bytes
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
- if event.match.empty?
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
- klass, code = failures.values.fetch(event.index)
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
- counts[reply] += 1
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 read_error(nil, phase, command, raw.join), cause: nil
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.scope(reuse: true) do
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 interaction_error(message, phase, command)
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.error(klass, "#{label} #{message}", phase: phase, command: command)
225
+ @session.build_error(klass, "#{label} #{message}", phase: phase, command: command)
202
226
  end
203
227
 
204
228
  # 根据 EOF、提示符、阶段和底层异常选择具体读取错误类型。
205
- def read_error(event, phase, command, output)
229
+ def build_read_error(event, phase, command, output)
206
230
  if event&.error.is_a?(Exception)
207
- return @session.error(TransportError, "transport read failed", phase: phase, command: command,
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.error(klass, "device response #{event&.error || :timeout}", phase: phase,
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
- @pattern = nil
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 scope(reuse: false)
118
+ def with_scope(reuse: false)
94
119
  return yield if reuse && @scoped
95
120
 
96
121
  previous = @secrets
97
- previous_pattern = @pattern
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
- @pattern = previous_pattern
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
- @secrets.sort_by! { |value| -value.bytesize }
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 = text.to_s.b
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 inspect = "#<#{self.class}>"
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 matches(text)
171
- ranges = []
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 execute(script)
30
+ def execute_script(script)
25
31
  script.each do |original|
26
- @session.command_scope(original) do
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
- response = query(command)
34
- step = CommandResult.new(command: command, output: response.output, prompt: response.prompt,
35
- duration: Expect.monotonic - started)
36
- steps << step
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 query(command)
58
+ def execute_command(command)
49
59
  command = Command.new(command) unless command.is_a?(Command)
50
- @session.exchange(command, timeout: @command_timeout, prompt: @prompt&.call(command))
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 failure(message)
60
- @session.error(ScriptError, message, phase: :script, command: @current_command)
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